This commit is contained in:
2026-04-01 18:31:33 +02:00
parent 6e23e32bb0
commit b5c7c5305a
95 changed files with 9609 additions and 2374 deletions

View File

View File

@@ -0,0 +1,161 @@
import asyncio
import logging
from datetime import datetime, timedelta
from sqlalchemy import delete, insert
from sqlalchemy.orm import Session
from game.card import _get_cards_async
from core.models import Card as CardModel
from core.models import GameChallenge as GameChallengeModel
from core.models import Notification as NotificationModel
from core.models import TradeProposal as TradeProposalModel
from core.models import User as UserModel
from core.database import SessionLocal
logger = logging.getLogger("app")
## Card pool management
POOL_MINIMUM = 1000
POOL_TARGET = 2000
POOL_BATCH_SIZE = 10
POOL_SLEEP = 4.0
# After this many consecutive empty batches, stop trying and wait for the cooldown.
POOL_MAX_CONSECUTIVE_EMPTY = 5
POOL_CIRCUIT_BREAKER_COOLDOWN = 600.0 # seconds
pool_filling = False
# asyncio monotonic timestamp; 0 means breaker is closed (no cooldown active)
_cb_open_until: float = 0.0
async def fill_card_pool():
global pool_filling, _cb_open_until
if pool_filling:
logger.info("Pool fill already in progress, skipping")
return
loop_time = asyncio.get_event_loop().time()
if loop_time < _cb_open_until:
remaining = int(_cb_open_until - loop_time)
logger.warning(f"Card generation circuit breaker open, skipping fill ({remaining}s remaining)")
return
pool_filling = True
db: Session = SessionLocal()
try:
unassigned = db.query(CardModel).filter(CardModel.user_id == None, CardModel.ai_used == False).count()
logger.info(f"Card pool has {unassigned} unassigned cards")
if unassigned >= POOL_MINIMUM:
logger.info("Pool sufficiently stocked, skipping fill")
return
needed = POOL_TARGET - unassigned
logger.info(f"Filling pool with {needed} cards")
fetched = 0
consecutive_empty = 0
while fetched < needed:
batch_size = min(POOL_BATCH_SIZE, needed - fetched)
cards = await _get_cards_async(batch_size)
if not cards:
consecutive_empty += 1
logger.warning(
f"Card generation batch returned 0 cards "
f"({consecutive_empty}/{POOL_MAX_CONSECUTIVE_EMPTY} consecutive empty batches)"
)
if consecutive_empty >= POOL_MAX_CONSECUTIVE_EMPTY:
_cb_open_until = asyncio.get_event_loop().time() + POOL_CIRCUIT_BREAKER_COOLDOWN
logger.error(
f"ALERT: Card generation circuit breaker tripped — {consecutive_empty} consecutive "
f"empty batches. Wikipedia/Wikirank API may be down. "
f"Next retry in {int(POOL_CIRCUIT_BREAKER_COOLDOWN)}s."
)
return
await asyncio.sleep(POOL_SLEEP)
continue
consecutive_empty = 0
db.execute(insert(CardModel).values([
dict(
name=card.name,
image_link=card.image_link,
card_rarity=card.card_rarity.name,
card_type=card.card_type.name,
text=card.text,
attack=card.attack,
defense=card.defense,
cost=card.cost,
user_id=None,
)
for card in cards
]))
db.commit()
fetched += len(cards)
logger.info(f"Pool fill progress: {fetched}/{needed}")
await asyncio.sleep(POOL_SLEEP)
finally:
pool_filling = False
db.close()
## Booster management
BOOSTER_MAX = 5
BOOSTER_COOLDOWN_HOURS = 5
def check_boosters(user: UserModel, db: Session) -> tuple[int, datetime|None]:
if user.boosters_countdown is None:
if user.boosters < BOOSTER_MAX:
user.boosters = BOOSTER_MAX
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)
## Periodic cleanup
CLEANUP_INTERVAL_SECONDS = 3600 # 1 hour
async def run_cleanup_loop():
# Brief startup delay so the DB is fully ready before first run
await asyncio.sleep(60)
while True:
try:
_delete_expired_records()
except Exception:
logger.exception("Periodic cleanup job failed")
await asyncio.sleep(CLEANUP_INTERVAL_SECONDS)
def _delete_expired_records():
now = datetime.now()
with SessionLocal() as db:
for model in (NotificationModel, TradeProposalModel, GameChallengeModel):
# Notification.expires_at is nullable — skip rows without an expiry.
# TradeProposal and GameChallenge always have expires_at, but the
# guard is harmless and makes the intent explicit.
result = db.execute(
delete(model).where(
model.expires_at != None, # noqa: E711
model.expires_at < now,
)
)
db.commit()
logger.info("Cleanup: deleted %d expired %s rows", result.rowcount, model.__tablename__)

View File

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

View File

@@ -0,0 +1,41 @@
"""
Manages persistent per-user WebSocket connections for the notification channel.
The DB is the source of truth — this layer just delivers live pushes to connected clients.
"""
import logging
from fastapi import WebSocket
logger = logging.getLogger("app")
# user_id (str) -> active WebSocket; replaced on reconnect
connections: dict[str, WebSocket] = {}
def register(user_id: str, ws: WebSocket) -> None:
connections[user_id] = ws
def unregister(user_id: str) -> None:
connections.pop(user_id, None)
async def send_notification(user_id: str, notification: dict) -> None:
"""Push a single notification to the user if they're connected. No-op otherwise."""
ws = connections.get(user_id)
if ws:
try:
await ws.send_json({"type": "push", "notification": notification})
except Exception as e:
# Stale connection — the disconnect handler will clean it up
logger.debug(f"WebSocket send failed (stale connection): {e}")
async def send_delete(user_id: str, notification_id: str) -> None:
"""Tell the client to remove a notification from its local list."""
ws = connections.get(user_id)
if ws:
try:
await ws.send_json({"type": "delete", "notification_id": notification_id})
except Exception as e:
logger.debug(f"WebSocket send failed (stale connection): {e}")

View File

@@ -0,0 +1,315 @@
import asyncio
import logging
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from fastapi import WebSocket
from sqlalchemy.orm import Session
from core.models import Card as CardModel, DeckCard as DeckCardModel
logger = logging.getLogger("app")
## Card transfer
def transfer_cards(
from_user_id: uuid.UUID,
to_user_id: uuid.UUID,
card_ids: list[uuid.UUID],
db: Session,
now: datetime,
) -> None:
"""
Reassigns card ownership, stamps received_at, removes deck memberships, and clears WTT.
Does NOT commit — caller owns the transaction.
Clearing WTT on transfer prevents a card from auto-appearing as tradeable on the new owner's
profile without them explicitly opting in.
"""
if not card_ids:
return
matched_cards = db.query(CardModel).filter(
CardModel.id.in_(card_ids),
CardModel.user_id == from_user_id,
).all()
# Bail out if any card is missing or no longer owned by the sender — a partial
# transfer would silently give the receiver fewer cards than agreed upon.
if len(matched_cards) != len(card_ids):
raise ValueError(
f"Expected {len(card_ids)} cards owned by {from_user_id}, "
f"found {len(matched_cards)}"
)
for card in matched_cards:
card.user_id = to_user_id
card.received_at = now
card.willing_to_trade = False
db.query(DeckCardModel).filter(DeckCardModel.card_id == card.id).delete(synchronize_session=False)
## Storage
@dataclass
class TradeOffer:
username: str
cards: list[dict] = field(default_factory=list)
accepted: bool = False
@dataclass
class TradeSession:
trade_id: str
offers: dict[str, TradeOffer] # user_id -> TradeOffer
connections: dict[str, WebSocket] = field(default_factory=dict)
active_trades: dict[str, TradeSession] = {}
@dataclass
class TradeQueueEntry:
user_id: str
username: str
websocket: WebSocket
trade_queue: list[TradeQueueEntry] = []
trade_queue_lock = asyncio.Lock()
## Serialization
def serialize_card_model(card: CardModel) -> dict:
return {
"id": str(card.id),
"name": card.name,
"card_rarity": card.card_rarity,
"card_type": card.card_type,
"image_link": card.image_link,
"attack": card.attack,
"defense": card.defense,
"cost": card.cost,
"text": card.text,
"generated_at": card.generated_at.isoformat() if card.generated_at else None,
"received_at": card.received_at.isoformat() if card.received_at else None,
"is_favorite": card.is_favorite,
"willing_to_trade": card.willing_to_trade,
}
def serialize_trade(session: TradeSession, perspective_user_id: str) -> dict:
partner_id = next(uid for uid in session.offers if uid != perspective_user_id)
my_offer = session.offers[perspective_user_id]
their_offer = session.offers[partner_id]
return {
"trade_id": session.trade_id,
"partner_username": their_offer.username,
"my_offer": {
"cards": my_offer.cards,
"accepted": my_offer.accepted,
},
"their_offer": {
"cards": their_offer.cards,
"accepted": their_offer.accepted,
},
}
## Broadcasting
async def broadcast_trade(session: TradeSession) -> None:
for user_id, ws in list(session.connections.items()):
try:
await ws.send_json({
"type": "state",
"state": serialize_trade(session, user_id),
})
except Exception as e:
logger.debug(f"WebSocket send failed (stale connection): {e}")
## Matchmaking
async def try_trade_match() -> None:
async with trade_queue_lock:
if len(trade_queue) < 2:
return
# Guard: same user queued twice
if trade_queue[0].user_id == trade_queue[1].user_id:
return
p1 = trade_queue.pop(0)
p2 = trade_queue.pop(0)
trade_id = str(uuid.uuid4())
session = TradeSession(
trade_id=trade_id,
offers={
p1.user_id: TradeOffer(username=p1.username),
p2.user_id: TradeOffer(username=p2.username),
},
connections={
p1.user_id: p1.websocket,
p2.user_id: p2.websocket,
},
)
active_trades[trade_id] = session
for entry in [p1, p2]:
try:
await entry.websocket.send_json({"type": "trade_start", "trade_id": trade_id})
except Exception as e:
logger.debug(f"WebSocket send failed (stale connection): {e}")
## Action handling
async def handle_trade_action(
trade_id: str,
user_id: str,
message: dict,
db: Session,
) -> None:
session = active_trades.get(trade_id)
if not session:
return
action = message.get("type")
ws = session.connections.get(user_id)
if action == "update_offer":
card_ids = message.get("card_ids", [])
if card_ids:
try:
parsed_ids = [uuid.UUID(cid) for cid in card_ids]
except ValueError:
if ws:
await ws.send_json({"type": "error", "message": "Invalid card IDs"})
return
db_cards = db.query(CardModel).filter(
CardModel.id.in_(parsed_ids),
CardModel.user_id == uuid.UUID(user_id),
).all()
if len(db_cards) != len(card_ids):
if ws:
await ws.send_json({"type": "error", "message": "Some cards are not in your collection"})
return
# Preserve the order of card_ids
card_map = {str(c.id): c for c in db_cards}
ordered = [card_map[cid] for cid in card_ids if cid in card_map]
session.offers[user_id].cards = [serialize_card_model(c) for c in ordered]
else:
session.offers[user_id].cards = []
# Any offer change unaccepts both sides
for offer in session.offers.values():
offer.accepted = False
await broadcast_trade(session)
elif action == "accept":
either_has_cards = any(len(o.cards) > 0 for o in session.offers.values())
if not either_has_cards:
return
# Validate ownership of offered cards one more time
my_offer = session.offers[user_id]
if my_offer.cards:
owned_count = db.query(CardModel).filter(
CardModel.id.in_([uuid.UUID(c["id"]) for c in my_offer.cards]),
CardModel.user_id == uuid.UUID(user_id),
).count()
if owned_count != len(my_offer.cards):
if ws:
await ws.send_json({"type": "error", "message": "Some offered cards are no longer in your collection"})
return
my_offer.accepted = True
if all(o.accepted for o in session.offers.values()):
await _complete_trade(trade_id, db)
else:
await broadcast_trade(session)
elif action == "unaccept":
session.offers[user_id].accepted = False
await broadcast_trade(session)
## Trade completion
async def _complete_trade(trade_id: str, db: Session) -> None:
session = active_trades.get(trade_id)
if not session:
return
# Re-check that both sides are still accepted and have a non-empty offer.
# A last-second unaccept or offer change (race or client bug) should abort.
if not all(o.accepted for o in session.offers.values()):
await broadcast_trade(session)
return
if not any(len(o.cards) > 0 for o in session.offers.values()):
for offer in session.offers.values():
offer.accepted = False
await broadcast_trade(session)
return
user_ids = list(session.offers.keys())
u1, u2 = user_ids[0], user_ids[1]
cards_u1 = session.offers[u1].cards # u1 gives these to u2
cards_u2 = session.offers[u2].cards # u2 gives these to u1
# Final ownership double-check before writing
def verify(from_id: str, card_dicts: list[dict]) -> bool:
if not card_dicts:
return True
card_uuids = [uuid.UUID(c["id"]) for c in card_dicts]
count = db.query(CardModel).filter(
CardModel.id.in_(card_uuids),
CardModel.user_id == uuid.UUID(from_id),
).count()
return count == len(card_uuids)
if not verify(u1, cards_u1) or not verify(u2, cards_u2):
db.rollback()
for ws in list(session.connections.values()):
try:
await ws.send_json({
"type": "error",
"message": "Trade failed: ownership check failed. Offers have been reset.",
})
except Exception as e:
logger.debug(f"WebSocket send failed (stale connection): {e}")
for offer in session.offers.values():
offer.accepted = False
await broadcast_trade(session)
return
# Transfer ownership and clear deck relationships
now = datetime.now()
transfer_cards(uuid.UUID(u1), uuid.UUID(u2), [uuid.UUID(c["id"]) for c in cards_u1], db, now)
transfer_cards(uuid.UUID(u2), uuid.UUID(u1), [uuid.UUID(c["id"]) for c in cards_u2], db, now)
db.commit()
active_trades.pop(trade_id, None)
for ws in list(session.connections.values()):
try:
await ws.send_json({"type": "trade_complete"})
except Exception as e:
logger.debug(f"WebSocket send failed (stale connection): {e}")
## Disconnect handling
async def handle_trade_disconnect(trade_id: str, user_id: str) -> None:
session = active_trades.get(trade_id)
if not session:
return
active_trades.pop(trade_id, None)
for uid, ws in list(session.connections.items()):
if uid == user_id:
continue
try:
await ws.send_json({
"type": "error",
"message": "Your trade partner disconnected. Trade cancelled.",
})
except Exception as e:
logger.debug(f"WebSocket send failed (stale connection): {e}")