From 5e7a6808ab7281d54ce185ec4a884aab092d082e Mon Sep 17 00:00:00 2001 From: NikolajDanger Date: Mon, 16 Mar 2026 17:53:59 +0100 Subject: [PATCH] :goat: --- backend/card.py | 4 + backend/database_functions.py | 26 ++ backend/log_conf.yaml | 10 + backend/main.py | 56 ++- backend/models.py | 2 + backend/requirements.txt | 8 + frontend/src/app.css | 5 + frontend/src/lib/Card.svelte | 6 +- frontend/src/lib/header.svelte | 193 ++++++++++ frontend/src/routes/+layout.svelte | 6 + frontend/src/routes/+page.svelte | 551 ++++++++++++++++++++++++++--- 11 files changed, 807 insertions(+), 60 deletions(-) create mode 100644 frontend/src/lib/header.svelte diff --git a/backend/card.py b/backend/card.py index 2186e50..6862f6a 100644 --- a/backend/card.py +++ b/backend/card.py @@ -95,6 +95,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = { "Q24862": CardType.artwork, # film "Q11032": CardType.artwork, # newspaper "Q25379": CardType.artwork, # play + "Q41298": CardType.artwork, # magazine "Q482994": CardType.artwork, # album "Q134556": CardType.artwork, # single "Q169930": CardType.artwork, # EP @@ -130,6 +131,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = { "Q3624078": CardType.location, # sovereign state "Q1093829": CardType.location, # city in the United States "Q7930989": CardType.location, # city/town + "Q3146899": CardType.location, # diocese of the catholic church "Q35145263": CardType.location, # natural geographic object "Q16521": CardType.life_form, # taxon @@ -155,6 +157,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = { "Q5419137": CardType.group, # veterans' organization "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 "Q7187": CardType.science_thing, # gene @@ -164,6 +167,7 @@ WIKIDATA_INSTANCE_TYPE_MAP = { "Q11276": CardType.science_thing, # globular cluster "Q898273": CardType.science_thing, # protein domain "Q168845": CardType.science_thing, # star cluster + "Q1341811": CardType.science_thing, # astronomical maser "Q1840368": CardType.science_thing, # cloud type "Q113145171": CardType.science_thing, # type of chemical entity diff --git a/backend/database_functions.py b/backend/database_functions.py index 21dc66b..b06db2e 100644 --- a/backend/database_functions.py +++ b/backend/database_functions.py @@ -1,10 +1,12 @@ import logging import asyncio +from datetime import datetime, timedelta from sqlalchemy.orm import Session from card import _get_cards_async from models import Card as CardModel +from models import User as UserModel from database import SessionLocal logger = logging.getLogger("app") @@ -59,3 +61,27 @@ async def fill_card_pool(): finally: pool_filling = False db.close() + +BOOSTER_MAX = 5 +BOOSTER_COOLDOWN_HOURS = 5 + +def check_boosters(user: UserModel, db: Session) -> tuple[int, datetime|None]: + if user.boosters_countdown is None: + user.boosters = 5 + db.commit() + return (user.boosters, user.boosters_countdown) + + now = datetime.now() + countdown = user.boosters_countdown + + while user.boosters < BOOSTER_MAX: + next_tick = countdown + timedelta(hours=BOOSTER_COOLDOWN_HOURS) + if now >= next_tick: + user.boosters += 1 + countdown = next_tick + else: + break + + user.boosters_countdown = countdown if user.boosters < BOOSTER_MAX else None + db.commit() + return (user.boosters, user.boosters_countdown) diff --git a/backend/log_conf.yaml b/backend/log_conf.yaml index 93635e0..2712919 100644 --- a/backend/log_conf.yaml +++ b/backend/log_conf.yaml @@ -37,6 +37,16 @@ loggers: handlers: - default propagate: no + passlib.utils.compat: + level: INFO + handlers: + - default + propagate: no + python_multipart.multipart: + level: INFO + handlers: + - default + propagate: no app: level: INFO handlers: diff --git a/backend/main.py b/backend/main.py index 525adb8..8e015d0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,6 +2,7 @@ import asyncio import logging import uuid from contextlib import asynccontextmanager +from datetime import datetime from sqlalchemy.orm import Session from fastapi import FastAPI, Depends, HTTPException, status @@ -9,11 +10,10 @@ from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel -from database import SessionLocal, get_db -from database_functions import fill_card_pool +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 card import _get_cards_async from auth import hash_password, verify_password, create_access_token, decode_access_token logger = logging.getLogger("app") @@ -49,16 +49,6 @@ app.add_middleware( allow_headers=["*"], ) -@app.get("/pack/{size}") -async def open_pack(size: int = 10, user: UserModel = Depends(get_current_user)): - cards = await _get_cards_async(size) - return [ - {**card._asdict(), - "card_type": card.card_type.name, - "card_rarity": card.card_rarity.name} - for card in cards - ] - @app.post("/register") def register(req: RegisterRequest, db: Session = Depends(get_db)): if db.query(UserModel).filter(UserModel.username == req.username).first(): @@ -82,3 +72,43 @@ def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get raise HTTPException(status_code=400, detail="Invalid username or password") token = create_access_token(str(user.id)) return {"access_token": token, "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.post("/open_pack") +async def open_pack(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)): + check_boosters(user, db) + + if user.boosters == 0: + raise HTTPException(status_code=400, detail="No booster packs available") + + cards = ( + db.query(CardModel) + .filter(CardModel.user_id == None) + .limit(5) + .all() + ) + + if len(cards) < 5: + 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() + + db.commit() + + asyncio.create_task(fill_card_pool()) + + 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 + ] \ No newline at end of file diff --git a/backend/models.py b/backend/models.py index b89deb8..a9d43e2 100644 --- a/backend/models.py +++ b/backend/models.py @@ -13,6 +13,8 @@ class User(Base): 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) cards: Mapped[list["Card"]] = relationship(back_populates="user") decks: Mapped[list["Deck"]] = relationship(back_populates="user") diff --git a/backend/requirements.txt b/backend/requirements.txt index e69de29..87b9222 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.135.1 +httpx==0.28.1 +passlib==1.7.4 +pydantic==2.12.5 +python_jose==3.5.0 +SQLAlchemy==2.0.48 +python-multipart +bcrypt==4.3.0 \ No newline at end of file diff --git a/frontend/src/app.css b/frontend/src/app.css index ade1216..02c6c3c 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -6,4 +6,9 @@ body { background: #0d0a04; +} + +html, body { + height: 100%; + overflow: hidden; } \ No newline at end of file diff --git a/frontend/src/lib/Card.svelte b/frontend/src/lib/Card.svelte index 0109aea..9e916a8 100644 --- a/frontend/src/lib/Card.svelte +++ b/frontend/src/lib/Card.svelte @@ -146,11 +146,11 @@ .card.foil.legendary::before { background: repeating-linear-gradient( 105deg, - rgba(255,215,0,0.30) 0%, + rgba(255,215,0,0.35) 0%, rgba(255,180,0,0.15) 15%, - rgba(255,255,180,0.35) 30%, + rgba(255,255,180,0.40) 30%, rgba(255,200,0,0.15) 45%, - rgba(255,215,0,0.30) 60% + rgba(255,215,0,0.35) 60% ); animation-duration: 1.8s; background-size: 300% 300%; diff --git a/frontend/src/lib/header.svelte b/frontend/src/lib/header.svelte new file mode 100644 index 0000000..f8555c7 --- /dev/null +++ b/frontend/src/lib/header.svelte @@ -0,0 +1,193 @@ + + +
+ + + + + +
+ +{#if menuOpen} +
+ +{/if} + + \ No newline at end of file diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index a50d726..302850f 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,10 +1,16 @@ +{#if page.url.pathname !== '/auth'} +
+{/if} + diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 61efca7..9a2ca27 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -5,67 +5,530 @@ let cards = $state([]); let loading = $state(false); + let boosters = $state(null); + let countdown = $state(null); + let countdownDisplay = $state(''); + let countdownInterval = null; - onMount(() => { - if (!localStorage.getItem('token')) goto('/auth'); + let phase = $state('idle'); + let flippedCards = $state([]); + let fanVisible = $state(false); + let packRef = $state(null); + let overlayPackRef = $state(null); + + onMount(async () => { + if (!localStorage.getItem('token')) { goto('/auth'); return; } + await fetchBoosters(); + return () => clearInterval(countdownInterval); }); + async function fetchBoosters() { + const res = await fetch('http://localhost:8000/boosters', { + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } + }); + if (res.status === 401) { goto('/auth'); return; } + const [count, countdownTs] = await res.json(); + boosters = count; + countdown = countdownTs ? new Date(countdownTs) : null; + startCountdown(); + } + + function startCountdown() { + clearInterval(countdownInterval); + if (!countdown || boosters >= 5) return; + countdownInterval = setInterval(() => { + const nextTick = new Date(countdown.getTime() + 5 * 60 * 60 * 1000); + const diff = nextTick - Date.now(); + if (diff <= 0) { clearInterval(countdownInterval); fetchBoosters(); return; } + const h = Math.floor(diff / 3600000); + const m = Math.floor((diff % 3600000) / 60000); + const s = Math.floor((diff % 60000) / 1000); + countdownDisplay = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; + }, 1000); + } + + function delay(ms) { return new Promise(r => setTimeout(r, ms)); } + + // Get the screen position of the idle pack so the overlay pack starts there + function getPackRect() { + if (!packRef) return { top: window.innerHeight / 2, left: window.innerWidth / 2 }; + const rect = packRef.getBoundingClientRect(); + return { top: rect.top, left: rect.left, width: rect.width, height: rect.height }; + } + + let overlayPackStyle = $state(''); + async function openPack() { + if (loading || boosters === 0 || phase !== 'idle') return; loading = true; - try { - const res = await fetch('http://localhost:8000/pack/10', { - headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } - }); - if (res.status === 401) { goto('/auth'); return; } - cards = await res.json(); - } catch (e) { - console.error(e); - } finally { - loading = false; + + const rect = getPackRect(); + overlayPackStyle = `position:fixed; top:${rect.top}px; left:${rect.left}px; width:${rect.width}px; height:${rect.height}px;`; + + phase = 'darkening'; + await delay(600); + + phase = 'ripping'; + await delay(900); + + phase = 'dropping'; + + // Fetch while pack is sliding away + const fetchPromise = fetch('http://localhost:8000/open_pack', { + method: 'POST', + headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } + }); + + await delay(700); + + const res = await fetchPromise; + if (!res.ok) { phase = 'idle'; loading = false; return; } + cards = await res.json(); + flippedCards = new Array(cards.length).fill(false); + boosters -= 1; + if (boosters < 5 && !countdown) { countdown = new Date(); startCountdown(); } + + phase = 'fanning'; + await delay(50); + fanVisible = true; + await delay(800); + + phase = 'flipping'; + const isMobile = window.innerWidth <= 640; + const indices = isMobile + ? [...Array(cards.length).keys()] // 0,1,2,3,4 top to bottom + : [...Array(cards.length).keys()].reverse(); // 4,3,2,1,0 right to left + + for (let i of indices) { + await delay(350); + flippedCards = flippedCards.map((v, idx) => idx === i ? true : v); } + + phase = 'done'; + loading = false; + } + + function close() { + phase = 'idle'; + cards = []; + flippedCards = []; + fanVisible = false; + } + + const FOIL_RARITIES = new Set(['super_rare', 'epic', 'legendary']); + + // Compute fan positions for each card + function fanStyle(i, total) { + const isMobile = window.innerWidth <= 640; + if (isMobile) { + return `--tx: 0px; --ty: 0px;`; + } + // Cards fan out horizontally from center + const spread = Math.min(320, (window.innerWidth - 100) / total); + const offset = (i - (total - 1) / 2) * spread; + return `--tx: ${offset}px; --ty: 0px;`; }
- - -
- {#each cards as card} - - {/each} +
+

+ {#if boosters !== null}{boosters}/5 BOOSTER PACKS REMAINING{/if} +

+ {#if boosters !== null && boosters < 5 && countdownDisplay} +

{countdownDisplay} until next pack

+ {/if}
+ + + {#if boosters !== null && boosters > 0 && phase === 'idle'} +
+ +
+ {/if} + + {#if phase !== 'idle'} +
+ + {#if phase === 'darkening' || phase === 'ripping' || phase === 'dropping'} +
+
+
+
+
+ +
+
+ {/if} + + {#if phase === 'fanning' || phase === 'flipping' || phase === 'done'} +
+ {#each cards as card, i} + {@const flipped = flippedCards[i]} + {@const foil = FOIL_RARITIES.has(card.card_rarity)} +
+
+
+
+
+
+ {/each} +
+ + {#if phase === 'done'} + + {/if} + {/if} + +
+ {/if}
\ No newline at end of file