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 @@
-
+
@@ -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