diff --git a/.gitignore b/.gitignore index 5d955a0..da91899 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ .vscode/ __pycache__/ .svelte-kit/ -versions/ \ No newline at end of file +.env \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..88e888b --- /dev/null +++ b/backend/.env.example @@ -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 \ No newline at end of file diff --git a/backend/alembic/versions/18537b3ac57d_add_stats_and_soft_delete_fields.py b/backend/alembic/versions/18537b3ac57d_add_stats_and_soft_delete_fields.py new file mode 100644 index 0000000..7151257 --- /dev/null +++ b/backend/alembic/versions/18537b3ac57d_add_stats_and_soft_delete_fields.py @@ -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 ### diff --git a/backend/alembic/versions/adee6bcc23e1_add_possword_reset_token_to_user.py b/backend/alembic/versions/adee6bcc23e1_add_possword_reset_token_to_user.py new file mode 100644 index 0000000..920685e --- /dev/null +++ b/backend/alembic/versions/adee6bcc23e1_add_possword_reset_token_to_user.py @@ -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 ### diff --git a/backend/alembic/versions/b342602d3eab_initial_schema.py b/backend/alembic/versions/b342602d3eab_initial_schema.py new file mode 100644 index 0000000..b1f641d --- /dev/null +++ b/backend/alembic/versions/b342602d3eab_initial_schema.py @@ -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 ### diff --git a/backend/alembic/versions/d8f5b502ec96_add_boosters_to_users.py b/backend/alembic/versions/d8f5b502ec96_add_boosters_to_users.py new file mode 100644 index 0000000..57cc91a --- /dev/null +++ b/backend/alembic/versions/d8f5b502ec96_add_boosters_to_users.py @@ -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 ### diff --git a/backend/auth.py b/backend/auth.py index f64e202..3062cc9 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -3,27 +3,43 @@ from datetime import datetime, timedelta from jose import JWTError, jwt from passlib.context import CryptContext +from config import JWT_SECRET_KEY + logger = logging.getLogger("app") -SECRET_KEY = "changethis" +SECRET_KEY = JWT_SECRET_KEY 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"]) def hash_password(password: str) -> str: - return pwd_context.hash(password) + return pwd_context.hash(password) 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: - expire = datetime.now() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - return jwt.encode({"sub": user_id, "exp": expire}, SECRET_KEY, algorithm=ALGORITHM) + expire = datetime.now() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + 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: - try: - payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - return payload.get("sub") - except JWTError: - return None \ No newline at end of file + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload.get("sub") + except JWTError: + return None \ No newline at end of file diff --git a/backend/card.py b/backend/card.py index 6862f6a..2bbded3 100644 --- a/backend/card.py +++ b/backend/card.py @@ -6,6 +6,9 @@ from urllib.parse import quote from datetime import datetime, timedelta from time import sleep +from config import WIKIRANK_USER_AGENT +HEADERS = {"User-Agent": WIKIRANK_USER_AGENT} + logger = logging.getLogger("app") class CardType(Enum): @@ -14,11 +17,11 @@ class CardType(Enum): location = 2 artwork = 3 life_form = 4 - conflict = 5 + event = 5 group = 6 science_thing = 7 vehicle = 8 - business = 9 + organization = 9 class CardRarity(Enum): common = 0 @@ -52,9 +55,11 @@ class Card(NamedTuple): rarity_text = f"Rarity: {self.card_rarity.name}" return_string += "┃"+f"{rarity_text:<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 += "┠"+"─"*50+"┨\n" + return_string += "┃"+f"{'*'*self.cost:{' '}<50}"+"┃\n" + return_string += "┠"+"─"*50+"┨\n" lines = [] words = self.text.split(" ") current_line = "" @@ -85,14 +90,16 @@ class Card(NamedTuple): WIKIDATA_INSTANCE_TYPE_MAP = { "Q5": CardType.person, # human - "Q15632617": CardType.person, # fictional human "Q215627": CardType.person, # person + "Q15632617": CardType.person, # fictional human + "Q22988604": CardType.person, # fictional human - "Q7889": CardType.artwork, # video game "Q1004": CardType.artwork, # comics - "Q15416": CardType.artwork, # television program + "Q7889": CardType.artwork, # video game "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 "Q25379": CardType.artwork, # play "Q41298": CardType.artwork, # magazine @@ -101,15 +108,18 @@ WIKIDATA_INSTANCE_TYPE_MAP = { "Q169930": CardType.artwork, # EP "Q196600": CardType.artwork, # media franchise "Q202866": CardType.artwork, # animated film + "Q277759": CardType.artwork, # book series "Q734698": CardType.artwork, # collectible card game "Q506240": CardType.artwork, # television film "Q738377": CardType.artwork, # student newspaper + "Q1259759": CardType.artwork, # miniseries "Q3305213": CardType.artwork, # painting "Q3177859": CardType.artwork, # dedicated deck card game "Q5398426": CardType.artwork, # television series "Q7725634": CardType.artwork, # literary work "Q1761818": CardType.artwork, # advertising campaign "Q1446621": CardType.artwork, # recital + "Q1868552": CardType.artwork, # local newspaper "Q63952888": CardType.artwork, # anime television series "Q47461344": CardType.artwork, # written work "Q71631512": CardType.artwork, # tabletop role-playing game supplement @@ -119,30 +129,53 @@ WIKIDATA_INSTANCE_TYPE_MAP = { "Q105543609": CardType.artwork, # musical work / composition "Q106499608": CardType.artwork, # literary reading "Q117467246": CardType.artwork, # animated television series + "Q106042566": CardType.artwork, # single album "Q515": CardType.location, # city "Q8502": CardType.location, # mountain "Q4022": CardType.location, # river "Q6256": CardType.location, # country + "Q15284": CardType.location, # municipality + "Q27686": CardType.location, # hotel "Q41176": CardType.location, # building "Q23442": CardType.location, # island "Q82794": CardType.location, # geographic region "Q34442": CardType.location, # road + "Q398141": CardType.location, # school district + "Q133056": CardType.location, # mountain pass "Q3624078": CardType.location, # sovereign state "Q1093829": CardType.location, # city in the United States "Q7930989": CardType.location, # city/town + "Q1250464": CardType.location, # realm "Q3146899": CardType.location, # diocese of the catholic church "Q35145263": CardType.location, # natural geographic object "Q16521": CardType.life_form, # taxon + "Q310890": CardType.life_form, # monotypic taxon "Q23038290": CardType.life_form, # fossil taxon + "Q12045585": CardType.life_form, # cattle breed - "Q198": CardType.conflict, # war - "Q8465": CardType.conflict, # civil war - "Q103495": CardType.conflict, # world war - "Q997267": CardType.conflict, # skirmish - "Q178561": CardType.conflict, # battle - "Q1361229": CardType.conflict, # conquest + "Q198": CardType.event, # war + "Q8465": CardType.event, # civil war + "Q141022": CardType.event, # eclipse + "Q103495": CardType.event, # world war + "Q350604": CardType.event, # armed conflict + "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 "Q476028": CardType.group, # association football club @@ -150,25 +183,43 @@ WIKIDATA_INSTANCE_TYPE_MAP = { "Q215380": CardType.group, # musical group "Q176799": CardType.group, # military unit "Q178790": CardType.group, # labor union + "Q851990": CardType.group, # people group "Q2367225": CardType.group, # university and college sports club "Q4801149": CardType.group, # artillery brigade "Q9248092": CardType.group, # infantry division "Q7210356": CardType.group, # political 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 "Q11446438": CardType.group, # female idol group "Q10517054": CardType.group, # handball 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 "Q8054": CardType.science_thing, # protein + "Q12136": CardType.science_thing, # disease "Q65943": CardType.science_thing, # theorem "Q12140": CardType.science_thing, # medication "Q11276": CardType.science_thing, # globular cluster + "Q83373": CardType.science_thing, # quasar "Q898273": CardType.science_thing, # protein domain + "Q134808": CardType.science_thing, # vaccine "Q168845": CardType.science_thing, # star cluster + "Q1491746": CardType.science_thing, # galaxy group "Q1341811": CardType.science_thing, # astronomical maser "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 "Q1420": CardType.vehicle, # car @@ -176,6 +227,11 @@ WIKIDATA_INSTANCE_TYPE_MAP = { "Q43193": CardType.vehicle, # truck "Q25956": CardType.vehicle, # space station "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 "Q673687": CardType.vehicle, # racing automobile "Q174736": CardType.vehicle, # destroyer @@ -184,21 +240,28 @@ WIKIDATA_INSTANCE_TYPE_MAP = { "Q830335": CardType.vehicle, # protected cruiser "Q928235": CardType.vehicle, # sloop-of-war "Q391022": CardType.vehicle, # research vessel + "Q202527": CardType.vehicle, # minesweeper "Q1185562": CardType.vehicle, # light aircraft carrier "Q7233751": CardType.vehicle, # post ship "Q3231690": CardType.vehicle, # automobile model "Q1428357": CardType.vehicle, # submarine class "Q1499623": CardType.vehicle, # destroyer escort "Q4818021": CardType.vehicle, # attack submarine + "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 httpx -HEADERS = {"User-Agent": "WikiTCG/1.0 (nikolaj@gade.gg)"} - async def _get_random_summary_async(client: httpx.AsyncClient) -> dict: try: response = await client.get( @@ -257,6 +320,24 @@ async def _get_page_summary_async(client: httpx.AsyncClient, title: str) -> dict 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]: try: 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, {}) claims = entity.get("claims", {}) 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", {}) 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") ) - if "P625" in claims: - return CardType.location, (qids[0] if qids != [] else ""), language_count - + # First pass: direct match for qid in qids: if qid in WIKIDATA_INSTANCE_TYPE_MAP: 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 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 items = response.json().get("items", []) return items[0]["views"] if items else None - except Exception: + except: return None 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_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) multiplier = RARITY_MULTIPLIER[rarity] @@ -415,6 +537,29 @@ def generate_cards(size: int) -> list[Card]: def generate_card(title: str) -> Card|None: 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): # print(card) @@ -436,4 +581,11 @@ def generate_card(title: str) -> Card|None: # for card in generate_cards(100): # if card.card_type == CardType.other: -# print(card) \ No newline at end of file +# 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")) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..918ccc3 --- /dev/null +++ b/backend/config.py @@ -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") \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index 827435d..8186c0e 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1,7 +1,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import DeclarativeBase, sessionmaker -DATABASE_URL = "postgresql://wikitcg:password@localhost/wikitcg" +from config import DATABASE_URL engine = create_engine(DATABASE_URL) SessionLocal = sessionmaker(bind=engine) diff --git a/backend/dockerfile b/backend/dockerfile new file mode 100644 index 0000000..9e194a3 --- /dev/null +++ b/backend/dockerfile @@ -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"] \ No newline at end of file diff --git a/backend/email_utils.py b/backend/email_utils.py new file mode 100644 index 0000000..03b7846 --- /dev/null +++ b/backend/email_utils.py @@ -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""" +
+

WikiTCG Password Reset

+

Hi {username},

+

Someone requested a password reset for your account. If this was you, click the link below:

+

+ + Reset Password + +

+

This link expires in 1 hour. If you didn't request this, you can safely ignore this email.

+

— WikiTCG

+
+ """, + }) \ No newline at end of file diff --git a/backend/game.py b/backend/game.py new file mode 100644 index 0000000..2fe2ed4 --- /dev/null +++ b/backend/game.py @@ -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 \ No newline at end of file diff --git a/backend/game_manager.py b/backend/game_manager.py new file mode 100644 index 0000000..cc8acc4 --- /dev/null +++ b/backend/game_manager.py @@ -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)) diff --git a/backend/main.py b/backend/main.py index 8e015d0..580f4f9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,20 +1,41 @@ import asyncio import logging import uuid +import re 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 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.middleware.cors import CORSMiddleware 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_functions import fill_card_pool, check_boosters, BOOSTER_MAX from models import Card as CardModel 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") @@ -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") return user +class ForgotPasswordRequest(BaseModel): + email: str + +class ResetPasswordWithTokenRequest(BaseModel): + token: str + new_password: str + @asynccontextmanager async def lifespan(app: FastAPI): asyncio.create_task(fill_card_pool()) @@ -42,15 +70,36 @@ async def lifespan(app: FastAPI): 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( CORSMiddleware, - allow_origins=["http://localhost:5173"], # SvelteKit's default dev port + allow_origins=CORS_ORIGINS, # SvelteKit's default dev port allow_methods=["*"], 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") 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(): raise HTTPException(status_code=400, detail="Username already taken") 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() if not user or not verify_password(form.password, user.password_hash): raise HTTPException(status_code=400, detail="Invalid username or password") - token = create_access_token(str(user.id)) - return {"access_token": token, "token_type": "bearer"} + return { + "access_token": create_access_token(str(user.id)), + "refresh_token": create_refresh_token(str(user.id)), + "token_type": "bearer", + } @app.get("/boosters") def get_boosters(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)) -> tuple[int,datetime|None]: return check_boosters(user, db) +@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") -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) 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: + asyncio.create_task(fill_card_pool()) raise HTTPException(status_code=503, detail="Card pool is low, please try again shortly") for card in cards: card.user_id = user.id - # was_full = user.boosters == BOOSTER_MAX - # user.boosters -= 1 - # if was_full: - # user.boosters_countdown = datetime.now() + was_full = user.boosters == BOOSTER_MAX + user.boosters -= 1 + if was_full: + user.boosters_countdown = datetime.now() db.commit() @@ -111,4 +175,357 @@ async def open_pack(user: UserModel = Depends(get_current_user), db: Session = D "card_rarity": card.card_rarity, "card_type": card.card_type} for card in cards - ] \ No newline at end of file + ] + +@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", + } diff --git a/backend/models.py b/backend/models.py index a9d43e2..8bebf75 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,61 +1,72 @@ import uuid 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.dialects.postgresql import UUID from database import Base class User(Base): - __tablename__ = "users" + __tablename__ = "users" - 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) - email: Mapped[str] = mapped_column(String, unique=True, nullable=False) - password_hash: Mapped[str] = mapped_column(String, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) - boosters: Mapped[int] = mapped_column(Integer, default=5, nullable=False) - boosters_countdown: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + 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) + email: Mapped[str] = mapped_column(String, unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + boosters: Mapped[int] = mapped_column(Integer, default=5, nullable=False) + 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") - decks: Mapped[list["Deck"]] = relationship(back_populates="user") + cards: Mapped[list["Card"]] = relationship(back_populates="user") + decks: Mapped[list["Deck"]] = relationship(back_populates="user") class Card(Base): - __tablename__ = "cards" + __tablename__ = "cards" - 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) - name: Mapped[str] = mapped_column(String, nullable=False) - image_link: Mapped[str] = mapped_column(String, nullable=True) - card_rarity: Mapped[str] = mapped_column(String, nullable=False) - card_type: Mapped[str] = mapped_column(String, nullable=False) - text: Mapped[str] = mapped_column(Text, nullable=True) - attack: Mapped[int] = mapped_column(Integer, nullable=False) - defense: 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) + 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) + name: Mapped[str] = mapped_column(String, nullable=False) + image_link: Mapped[str] = mapped_column(String, nullable=True) + card_rarity: Mapped[str] = mapped_column(String, nullable=False) + card_type: Mapped[str] = mapped_column(String, nullable=False) + text: Mapped[str] = mapped_column(Text, nullable=True) + attack: Mapped[int] = mapped_column(Integer, nullable=False) + defense: 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) + 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") - deck_cards: Mapped[list["DeckCard"]] = relationship(back_populates="card") + user: Mapped["User | None"] = relationship(back_populates="cards") + deck_cards: Mapped[list["DeckCard"]] = relationship(back_populates="card") class Deck(Base): - __tablename__ = "decks" + __tablename__ = "decks" - 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) - name: Mapped[str] = mapped_column(String, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + 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) + name: Mapped[str] = mapped_column(String, nullable=False) + 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") - deck_cards: Mapped[list["DeckCard"]] = relationship(back_populates="deck") + user: Mapped["User"] = relationship(back_populates="decks") + deck_cards: Mapped[list["DeckCard"]] = relationship(back_populates="deck") 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) - card_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("cards.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) - deck: Mapped["Deck"] = relationship(back_populates="deck_cards") - card: Mapped["Card"] = relationship(back_populates="deck_cards") \ No newline at end of file + deck: Mapped["Deck"] = relationship(back_populates="deck_cards") + card: Mapped["Card"] = relationship(back_populates="deck_cards") \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 87b9222..ae267b4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,7 +2,9 @@ fastapi==0.135.1 httpx==0.28.1 passlib==1.7.4 pydantic==2.12.5 +python-dotenv==1.2.2 python_jose==3.5.0 +resend==2.25.0 +slowapi==0.1.9 SQLAlchemy==2.0.48 -python-multipart -bcrypt==4.3.0 \ No newline at end of file +bcrypt==4.3.0 diff --git a/backend/run b/backend/run deleted file mode 100755 index fe5245c..0000000 --- a/backend/run +++ /dev/null @@ -1 +0,0 @@ -uvicorn main:app --reload --log-config=log_conf.yaml \ No newline at end of file diff --git a/backend/test_game.py b/backend/test_game.py new file mode 100644 index 0000000..ff83c06 --- /dev/null +++ b/backend/test_game.py @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..056ae94 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..7f13e1b --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +PUBLIC_API_URL=http://localhost:8000 \ No newline at end of file diff --git a/frontend/dockerfile b/frontend/dockerfile new file mode 100644 index 0000000..9a7b495 --- /dev/null +++ b/frontend/dockerfile @@ -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 \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..d552351 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,8 @@ +server { + listen 80; + + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } +} \ No newline at end of file diff --git a/frontend/src/lib/Card.svelte b/frontend/src/lib/Card.svelte index 9e916a8..e90722d 100644 --- a/frontend/src/lib/Card.svelte +++ b/frontend/src/lib/Card.svelte @@ -1,5 +1,5 @@ -
+
@@ -77,7 +77,7 @@
@@ -133,11 +133,11 @@ .card.foil.epic::before { background: repeating-linear-gradient( 105deg, - rgba(255,0,128,0.3) 0%, - rgba(255,200,0,0.3) 10%, - rgba(0,255,128,0.3) 20%, - rgba(0,200,255,0.3) 30%, - rgba(128,0,255,0.3) 40%, + rgba(255,0,128,0.28) 0%, + rgba(255,200,0,0.26) 10%, + rgba(0,255,128,0.24) 20%, + rgba(0,200,255,0.26) 30%, + rgba(128,0,255,0.28) 40%, rgba(255,0,128,0.3) 50% ); background-size: 300% 300%; @@ -147,10 +147,10 @@ background: repeating-linear-gradient( 105deg, rgba(255,215,0,0.35) 0%, - rgba(255,180,0,0.15) 15%, - rgba(255,255,180,0.40) 30%, - rgba(255,200,0,0.15) 45%, - rgba(255,215,0,0.35) 60% + rgba(255,180,0,0.08) 15%, + rgba(255,255,180,0.35) 30%, + rgba(255,200,0,0.08) 45%, + rgba(255,215,0,0.30) 60% ); animation-duration: 1.8s; background-size: 300% 300%; @@ -348,4 +348,9 @@ font-family: 'Cinzel', serif; line-height: 1; } + + .card.no-hover:hover { + transform: none; + box-shadow: 0 4px 24px rgba(0,0,0,0.5); + } \ No newline at end of file diff --git a/frontend/src/lib/DeckTypeBadge.svelte b/frontend/src/lib/DeckTypeBadge.svelte new file mode 100644 index 0000000..d61cd42 --- /dev/null +++ b/frontend/src/lib/DeckTypeBadge.svelte @@ -0,0 +1,31 @@ + + +{#if deckType} + + {deckType} + +{/if} + + \ No newline at end of file diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js new file mode 100644 index 0000000..e813f8f --- /dev/null +++ b/frontend/src/lib/api.js @@ -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; +} \ No newline at end of file diff --git a/frontend/src/lib/header.svelte b/frontend/src/lib/header.svelte index f8555c7..2a0995d 100644 --- a/frontend/src/lib/header.svelte +++ b/frontend/src/lib/header.svelte @@ -2,19 +2,14 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; - function logout() { - localStorage.removeItem('token'); - goto('/auth'); - close(); - } - let menuOpen = $state(false); const links = [ - { href: '/', label: 'Booster Packs' }, - { href: '/cards', label: 'Cards' }, - { href: '/decks', label: 'Decks' }, - { href: '/play', label: 'Play' }, + { href: '/', label: 'Booster Packs' }, + { href: '/cards', label: 'Cards' }, + { href: '/decks', label: 'Decks' }, + { href: '/play', label: 'Play' }, + { href: '/how-to-play', label: 'How to Play' }, ]; function close() { menuOpen = false; } @@ -27,7 +22,7 @@ {#each links as link} {link.label} {/each} - + Profile {/if} @@ -159,33 +153,6 @@ 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) { nav.desktop { display: none; } .hamburger { display: flex; } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 302850f..e612055 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -7,7 +7,7 @@ let { children } = $props(); -{#if page.url.pathname !== '/auth'} +{#if !['auth', 'forgot-password'].some(p => page.url.pathname.startsWith(`/${p}`))}
{/if} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 9a2ca27..a7d152e 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,4 +1,6 @@ + +
+
+
+ Sort by + {#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity']] as [val, lbl]} + + {/each} + + +
+ + {#if filtersOpen} +
+
+
+ Rarity + +
+
+ {#each RARITIES as r} + + {/each} +
+
+ +
+
+ Type + +
+
+ {#each TYPES as t} + + {/each} +
+
+ +
+
+ Cost + +
+
+ Min: {costMin} + { if (costMin > costMax) costMax = costMin; }} /> + Max: {costMax} + { if (costMax < costMin) costMin = costMax; }} /> +
+
+
+ {/if} +
+ + {#if loading} +

Loading your cards...

+ {:else if filtered.length === 0} +

No cards match your filters.

+ {:else} +

{filtered.length} card{filtered.length === 1 ? '' : 's'}

+
+ {#each filtered as card (card.id)} + + {/each} +
+ {/if} + {#if selectedCard} +
+
e.stopPropagation()}> + + +

{actionMessage}

+ +
+
+ {/if} +
+ + \ No newline at end of file diff --git a/frontend/src/routes/decks/+page.svelte b/frontend/src/routes/decks/+page.svelte new file mode 100644 index 0000000..e01c634 --- /dev/null +++ b/frontend/src/routes/decks/+page.svelte @@ -0,0 +1,441 @@ + + +
+
+

Your Decks

+ +
+ + {#if loading} +

Loading decks...

+ {:else if decks.length === 0} +

You have no decks yet.

+ {:else} + + + + + + + + + + + + + + {#each decks as deck} + {@const wr = winRate(deck)} + + + + + + + + + + {/each} + +
NameCardsTypePlayedW / LWin %
{deck.name} + {deck.card_count}/20 + + + {deck.times_played} + {#if deck.times_played > 0} + {deck.wins} + / + {deck.losses} + {:else} + + {/if} + + {#if wr !== null} + = 50} class:bad-wr={wr < 50}>{wr}% + {:else} + + {/if} + + + +
+ {/if} + + + {#if editConfirm} +
editConfirm = null}> + +
+ {/if} + + + {#if deleteConfirm} +
deleteConfirm = null}> + +
+ {/if} + +
+ + \ No newline at end of file diff --git a/frontend/src/routes/decks/[id]/+page.svelte b/frontend/src/routes/decks/[id]/+page.svelte new file mode 100644 index 0000000..c184704 --- /dev/null +++ b/frontend/src/routes/decks/[id]/+page.svelte @@ -0,0 +1,534 @@ + + +
+
+
+ {#if editingName} + e.key === 'Enter' && commitName()} + autofocus + /> + {:else} + + {/if} + +
+ + {selectedIds.size}/20 + + +
+
+ +
+ Sort by + {#each [['name','Name'],['cost','Cost'],['attack','Attack'],['defense','Defense'],['rarity','Rarity']] as [val, lbl]} + + {/each} + + +
+ + {#if filtersOpen} +
+
+
+ Rarity + +
+
+ {#each RARITIES as r} + + {/each} +
+
+ +
+
+ Type + +
+
+ {#each TYPES as t} + + {/each} +
+
+ +
+
+ Cost + +
+
+ Min: {costMin} + { if (costMin > costMax) costMax = costMin; }} /> + Max: {costMax} + { if (costMax < costMin) costMin = costMax; }} /> +
+
+
+ {/if} +
+ + {#if loading} +

Loading...

+ {:else if filtered.length === 0} +

No cards match your filters.

+ {:else} +
+ {#each filtered as card (card.id)} + + {/each} +
+ {/if} +
+ + \ No newline at end of file diff --git a/frontend/src/routes/forgot-password/+page.svelte b/frontend/src/routes/forgot-password/+page.svelte new file mode 100644 index 0000000..077f2f5 --- /dev/null +++ b/frontend/src/routes/forgot-password/+page.svelte @@ -0,0 +1,151 @@ + + +
+
+ {#if submitted} +

Check Your Email

+

If that email address is registered, you'll receive a password reset link shortly.

+ {:else} +

Forgot Password

+

Enter your email address and we'll send you a reset link.

+ + +

{error}

+ + + ← Back to login + {/if} +
+
+ + \ No newline at end of file diff --git a/frontend/src/routes/forgot-password/reset/+page.svelte b/frontend/src/routes/forgot-password/reset/+page.svelte new file mode 100644 index 0000000..f80dbde --- /dev/null +++ b/frontend/src/routes/forgot-password/reset/+page.svelte @@ -0,0 +1,172 @@ + + +
+
+ {#if !token} +

Invalid Link

+

This reset link is invalid. Please request a new one.

+ Request New Link + {:else if success} +

Password Updated

+

Your password has been changed. You can now log in.

+ + {:else} +

Set New Password

+ +
+ + + + +
+ +

{error}

+ + + {/if} +
+
+ + \ No newline at end of file diff --git a/frontend/src/routes/how-to-play/+page.svelte b/frontend/src/routes/how-to-play/+page.svelte new file mode 100644 index 0000000..4e9a6c6 --- /dev/null +++ b/frontend/src/routes/how-to-play/+page.svelte @@ -0,0 +1,545 @@ + + +
+
+

How to Play

+
+

Understanding Your Cards

+
+
+
+ +
+
+
+ {exampleCard.name} + Person +
+
+ {exampleCard.name} +
R
+ + W + +
+ {#each { length: exampleCard.cost } as _} +
+ {/each} +
+
+
+
{exampleCard.text}
+ +
+
+
+ + +
+ {#each markerPositions as pos} +
+
{pos.number}
+
+ {/each} +
+
+ +
    + {#each annotations as a} +
  1. + {a.number} +
    + {a.label} + {a.description} +
    +
  2. + {/each} +
+
+
+ +
+

Taking a Turn

+
+
+
+

Energy

+

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.

+
+
+
+

Drawing

+

At the start of your turn you draw cards until you have 5 in your hand. You cannot draw more than 5 cards.

+
+
+
+

Playing Cards

+

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.

+
+
+
🗡
+

Sacrificing

+

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.

+
+
+
+ +
+

Combat

+

When you end your turn, all your cards attack simultaneously. Each card attacks the card directly opposite it:

+
+
+
+

Opposed Attack

+

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.

+
+
+
+

Direct Attack

+

If the opposing slot is empty, your card attacks the opponent's life total directly, dealing damage equal to its ATK.

+
+ +
+
+ +
+

Winning & Losing

+
+
+
💀
+

Life Total

+

Each player starts with 500 life. Reduce your opponent's life to zero to win.

+
+
+
🃏
+

No Cards Left

+

If you have cards in play and your opponent has no playable cards remaining in their deck, hand, or board, you win.

+
+
+
+

Timeout

+

Each player has 2 minutes per turn. If your opponent's timer runs out, you win.

+
+
+
🔌
+

Disconnect

+

If your opponent disconnects and does not reconnect within 15 seconds, you win.

+
+
+
+
+
+ + \ No newline at end of file diff --git a/frontend/src/routes/play/+page.svelte b/frontend/src/routes/play/+page.svelte new file mode 100644 index 0000000..7e7c7c5 --- /dev/null +++ b/frontend/src/routes/play/+page.svelte @@ -0,0 +1,1053 @@ + + +
+ + {#if phase === 'idle'} +
+

Find a Match

+ {#if decks.length === 0} +

You need a deck to play. Build one first.

+ {:else} +
+ + +
+

{selectedDeck && selectedDeck.card_count < 20 ? `Deck must have 20 cards (${selectedDeck.card_count}/20)` : ''}

+
+ + +
+ {/if} +
+ + {:else if phase === 'queuing'} +
+
+

Waiting for an opponent...

+ +
+ + {:else if phase === 'playing' && gameState} +
+ + + +
+
+ {#each opp.board as card, slot} +
+ {#if card && !destroyed.has(card.instance_id)} +
+
+ +
+
+ {:else} +
+ {/if} +
+ {/each} +
+ +
+ + {isMyTurn ? 'Your turn' : `${opp.username}'s turn`} + + {#if secondsRemaining <= TIMER_WARNING} + + {Math.ceil(secondsRemaining)}s + + {/if} +
+ +
+ {#each me.board as card, slot} +
{ + if (sacrificeMode && card) { sacrifice(slot); } + else if (card === null) { clickSlot(slot); } + }} + > + {#if card && !destroyed.has(card.instance_id)} +
+
+ +
+ {#if sacrificeMode} +
🗡
+ {/if} +
+ {:else} +
+ {#if selectedHandIndex !== null} + Play here + {/if} +
+ {/if} +
+ {/each} +
+
+ + + +
+ +
+ {#each me.hand as card, i} + + {/each} +
+ + {#if error} +
{error}
+ {/if} + + {:else if phase === 'ended' && gameState?.result} +
+

+ {gameState.result.winner_id === myId ? 'Victory' : 'Defeat'} +

+

{gameState.result.reason}

+ +
+ {/if} + +
+ + \ No newline at end of file diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte new file mode 100644 index 0000000..bf960ef --- /dev/null +++ b/frontend/src/routes/profile/+page.svelte @@ -0,0 +1,344 @@ + + +
+ {#if loading} +

Loading...

+ {:else if profile} +
+ +
+
{profile.username[0].toUpperCase()}
+
+

{profile.username}

+ +

Member since {new Date(profile.created_at).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })}

+
+ + Change password +
+ +
+ +

Stats

+
+
+ Wins + {profile.wins} +
+
+ Losses + {profile.losses} +
+
+ Games Played + {profile.wins + profile.losses} +
+
+ Win Rate + = 50} class:bad-wr={profile.win_rate !== null && profile.win_rate < 50}> + {profile.win_rate !== null ? `${profile.win_rate}%` : '—'} + +
+
+ +
+ +

Highlights

+
+
+ Most Played Deck + {#if profile.most_played_deck} + {profile.most_played_deck.name} + {profile.most_played_deck.times_played} games + {:else} + No games played yet + {/if} +
+ +
+ Most Played Card + {#if profile.most_played_card} +
+ {#if profile.most_played_card.image_link} + {profile.most_played_card.name} + {/if} +
+ {profile.most_played_card.name} + {profile.most_played_card.times_played} times played + {profile.most_played_card.card_type} · {profile.most_played_card.card_rarity} +
+
+ {:else} + No cards played yet + {/if} +
+
+ +
+ {/if} +
+ + \ No newline at end of file diff --git a/frontend/src/routes/reset-password/+page.svelte b/frontend/src/routes/reset-password/+page.svelte new file mode 100644 index 0000000..a783c13 --- /dev/null +++ b/frontend/src/routes/reset-password/+page.svelte @@ -0,0 +1,191 @@ + + +
+
+ {#if success} +

Password Updated

+

Your password has been changed successfully.

+ + {:else} +

Reset Password

+ +
+ + + + + + + + +
+ +

{error}

+ + + + + {/if} +
+
+ + \ No newline at end of file