import asyncio import uuid from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect from pydantic import BaseModel from sqlalchemy import func from sqlalchemy.orm import Session, joinedload from services import notification_manager from core.database import get_db from services.database_functions import fill_card_pool from core.dependencies import accept_and_authenticate_ws, get_current_user, limiter, parse_uuid from game.manager import ( QueueEntry, active_games, connections, create_challenge_game, create_solo_game, handle_action, handle_disconnect, handle_timeout_claim, load_deck_cards, queue, queue_lock, serialize_state, try_match, ) from core.models import Card as CardModel from core.models import Deck as DeckModel from core.models import DeckCard as DeckCardModel from core.models import GameChallenge as GameChallengeModel from core.models import Notification as NotificationModel from core.models import User as UserModel from routers.notifications import _serialize_notification router = APIRouter() DECK_MAX_COST = 50 def deck_total_cost(deck_id: uuid.UUID, db: Session) -> int: card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck_id).all()] return db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0 def deck_cost_error(deck_id: uuid.UUID, db: Session, label: str = "Deck") -> str | None: """The 50-cost rule. Enforced at game start rather than at deck save, so every path into a game has to check it — the challenge-accept path previously didn't.""" total = deck_total_cost(deck_id, db) if total == 0 or total > DECK_MAX_COST: return f"{label} total cost must be between 1 and {DECK_MAX_COST}" return None def _serialize_challenge(c: GameChallengeModel, current_user_id: uuid.UUID) -> dict: deck = c.challenger_deck return { "id": str(c.id), "status": c.status, "direction": "outgoing" if c.challenger_id == current_user_id else "incoming", "challenger_username": c.challenger.username, "challenged_username": c.challenged.username, "deck_name": deck.name if deck else "Unknown Deck", "deck_id": str(c.challenger_deck_id), "created_at": c.created_at.isoformat(), "expires_at": c.expires_at.isoformat(), } # ── WebSocket game matchmaking ──────────────────────────────────────────────── @router.websocket("/ws/queue") async def queue_endpoint(websocket: WebSocket, deck_id: str, db: Session = Depends(get_db)): user_id = await accept_and_authenticate_ws(websocket) if not user_id: return # deck_id is a client-supplied query param. parse_uuid raises HTTPException, which # means nothing on an open socket, so validate here and report it the way this # handler reports everything else. try: parsed_deck_id = uuid.UUID(deck_id) except ValueError: await websocket.send_json({"type": "error", "message": "Invalid deck_id"}) await websocket.close(code=1008) return deck = db.query(DeckModel).filter( DeckModel.id == parsed_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 cost_err = deck_cost_error(deck.id, db) if cost_err: await websocket.send_json({"type": "error", "message": cost_err}) 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] @router.websocket("/ws/game/{game_id}") async def game_endpoint(websocket: WebSocket, game_id: str, db: Session = Depends(get_db)): user_id = await accept_and_authenticate_ws(websocket) if not user_id: return if game_id not in active_games: await websocket.close(code=1008) return if user_id not in active_games[game_id].players: 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)) # ── Game challenges ─────────────────────────────────────────────────────────── class CreateGameChallengeRequest(BaseModel): deck_id: str class AcceptGameChallengeRequest(BaseModel): deck_id: str @router.post("/users/{username}/challenge") @limiter.limit("10/minute") async def create_game_challenge( request: Request, username: str, req: CreateGameChallengeRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db), ): target = db.query(UserModel).filter(UserModel.username == username).first() if not target: raise HTTPException(status_code=404, detail="User not found") if target.id == user.id: raise HTTPException(status_code=400, detail="Cannot challenge yourself") try: deck_id = uuid.UUID(req.deck_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid deck_id") deck = db.query(DeckModel).filter(DeckModel.id == deck_id, DeckModel.user_id == user.id, DeckModel.deleted == False).first() if not deck: raise HTTPException(status_code=404, detail="Deck not found") existing = db.query(GameChallengeModel).filter( GameChallengeModel.status == "pending", ( ((GameChallengeModel.challenger_id == user.id) & (GameChallengeModel.challenged_id == target.id)) | ((GameChallengeModel.challenger_id == target.id) & (GameChallengeModel.challenged_id == user.id)) ) ).first() if existing: raise HTTPException(status_code=400, detail="A pending challenge already exists between you two") now = datetime.now() challenge = GameChallengeModel( challenger_id=user.id, challenged_id=target.id, challenger_deck_id=deck_id, expires_at=now + timedelta(minutes=5), ) db.add(challenge) db.flush() notif = NotificationModel( user_id=target.id, type="game_challenge", expires_at=challenge.expires_at, payload={ "challenge_id": str(challenge.id), "from_username": user.username, "deck_name": deck.name, }, ) db.add(notif) db.commit() await notification_manager.send_notification(str(target.id), _serialize_notification(notif)) return {"challenge_id": str(challenge.id)} @router.post("/challenges/{challenge_id}/accept") @limiter.limit("20/minute") async def accept_game_challenge( request: Request, challenge_id: str, req: AcceptGameChallengeRequest, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db), ): try: cid = uuid.UUID(challenge_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid challenge_id") challenge = db.query(GameChallengeModel).filter(GameChallengeModel.id == cid).with_for_update().first() if not challenge: raise HTTPException(status_code=404, detail="Challenge not found") if challenge.challenged_id != user.id: raise HTTPException(status_code=403, detail="Not authorized") now = datetime.now() if challenge.status == "pending" and now > challenge.expires_at: challenge.status = "expired" db.commit() raise HTTPException(status_code=400, detail="Challenge has expired") if challenge.status != "pending": raise HTTPException(status_code=400, detail=f"Challenge is already {challenge.status}") try: deck_id = uuid.UUID(req.deck_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid deck_id") deck = db.query(DeckModel).filter(DeckModel.id == deck_id, DeckModel.user_id == user.id, DeckModel.deleted == False).first() if not deck: raise HTTPException(status_code=404, detail="Deck not found") # Verify challenger's deck still exists — it could have been deleted since the challenge was sent challenger_deck = db.query(DeckModel).filter( DeckModel.id == challenge.challenger_deck_id, DeckModel.deleted == False, ).first() if not challenger_deck: raise HTTPException(status_code=400, detail="The challenger's deck no longer exists") # Both decks, because neither was cost-checked when the challenge was created. for d, label in ((deck, "Your deck"), (challenger_deck, "The challenger's deck")): cost_err = deck_cost_error(d.id, db, label) if cost_err: raise HTTPException(status_code=400, detail=cost_err) try: game_id = create_challenge_game( str(challenge.challenger_id), str(challenge.challenger_deck_id), str(challenge.challenged_id), str(deck_id), db, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) challenge.status = "accepted" # Delete the original challenge notification from the challenged player's bell old_notif = db.query(NotificationModel).filter( NotificationModel.user_id == user.id, NotificationModel.type == "game_challenge", NotificationModel.payload["challenge_id"].astext == str(challenge.id), ).first() deleted_notif_id = str(old_notif.id) if old_notif else None if old_notif: db.delete(old_notif) # Notify the challenger that their challenge was accepted response_notif = NotificationModel( user_id=challenge.challenger_id, type="game_challenge", payload={ "challenge_id": str(challenge.id), "status": "accepted", "game_id": game_id, "from_username": user.username, }, ) db.add(response_notif) db.commit() if deleted_notif_id: await notification_manager.send_delete(str(user.id), deleted_notif_id) await notification_manager.send_notification(str(challenge.challenger_id), _serialize_notification(response_notif)) return {"game_id": game_id} @router.post("/challenges/{challenge_id}/decline") @limiter.limit("20/minute") async def decline_game_challenge( request: Request, challenge_id: str, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db), ): try: cid = uuid.UUID(challenge_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid challenge_id") challenge = db.query(GameChallengeModel).filter(GameChallengeModel.id == cid).first() if not challenge: raise HTTPException(status_code=404, detail="Challenge not found") if challenge.challenger_id != user.id and challenge.challenged_id != user.id: raise HTTPException(status_code=403, detail="Not authorized") now = datetime.now() if challenge.status == "pending" and now > challenge.expires_at: challenge.status = "expired" db.commit() raise HTTPException(status_code=400, detail="Challenge has already expired") if challenge.status != "pending": raise HTTPException(status_code=400, detail=f"Challenge is already {challenge.status}") is_withdrawal = challenge.challenger_id == user.id challenge.status = "withdrawn" if is_withdrawal else "declined" # Remove the notification from the other party's bell if is_withdrawal: # Challenger withdrawing: remove challenge notif from challenged player's bell notif = db.query(NotificationModel).filter( NotificationModel.user_id == challenge.challenged_id, NotificationModel.type == "game_challenge", NotificationModel.payload["challenge_id"].astext == str(challenge.id), ).first() recipient_id = str(challenge.challenged_id) else: # Challenged player declining: remove challenge notif from their own bell notif = db.query(NotificationModel).filter( NotificationModel.user_id == user.id, NotificationModel.type == "game_challenge", NotificationModel.payload["challenge_id"].astext == str(challenge.id), ).first() recipient_id = str(user.id) deleted_notif_id = str(notif.id) if notif else None if notif: db.delete(notif) db.commit() if deleted_notif_id: await notification_manager.send_delete(recipient_id, deleted_notif_id) return {"ok": True} @router.get("/challenges") def get_challenges(user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)): now = datetime.now() # Lazy-expire pending challenges past deadline db.query(GameChallengeModel).filter( GameChallengeModel.status == "pending", GameChallengeModel.expires_at < now, (GameChallengeModel.challenger_id == user.id) | (GameChallengeModel.challenged_id == user.id), ).update({"status": "expired"}) db.commit() challenges = db.query(GameChallengeModel).options( joinedload(GameChallengeModel.challenger_deck) ).filter( (GameChallengeModel.challenger_id == user.id) | (GameChallengeModel.challenged_id == user.id) ).order_by(GameChallengeModel.created_at.desc()).all() return [_serialize_challenge(c, user.id) for c in challenges] @router.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"} @router.post("/game/solo") # Tightest limit in the app: every call reserves 500 pool cards and leaves a game in # memory that nothing reaps, so a loop here drains the shared pool and grows the heap. @limiter.limit("10/hour") async def start_solo_game(request: Request, deck_id: str, difficulty: int = 5, user: UserModel = Depends(get_current_user), db: Session = Depends(get_db)): if difficulty < 1 or difficulty > 10: raise HTTPException(status_code=400, detail="Difficulty must be between 1 and 10") deck = db.query(DeckModel).filter( DeckModel.id == parse_uuid(deck_id, "deck_id"), DeckModel.user_id == user.id ).first() if not deck: raise HTTPException(status_code=404, detail="Deck not found") cost_err = deck_cost_error(deck.id, db) if cost_err: raise HTTPException(status_code=400, detail=cost_err) 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(500).all() if len(ai_cards) == 0: raise HTTPException(status_code=503, detail="Not enough cards in pool for AI deck") for card in ai_cards: card.ai_used = True db.commit() game_id = create_solo_game(str(user.id), user.username, player_cards, ai_cards, deck_id, difficulty) asyncio.create_task(fill_card_pool()) return {"game_id": game_id}