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.auth import decode_access_token from core.database import get_db from services.database_functions import fill_card_pool from core.dependencies import get_current_user, get_user_id_from_request, limiter 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() 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)): 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_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()] total_cost = db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0 if total_cost == 0 or total_cost > 50: await websocket.send_json({"type": "error", "message": "Deck total cost must be between 1 and 50"}) 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)): 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 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", key_func=get_user_id_from_request) 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") async def accept_game_challenge( 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") 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") async def decline_game_challenge( 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") async def start_solo_game(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 == uuid.UUID(deck_id), DeckModel.user_id == user.id ).first() if not deck: raise HTTPException(status_code=404, detail="Deck not found") card_ids = [dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()] total_cost = db.query(func.sum(CardModel.cost)).filter(CardModel.id.in_(card_ids)).scalar() or 0 if total_cost == 0 or total_cost > 50: raise HTTPException(status_code=400, detail="Deck total cost must be between 1 and 50") 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}