🐐
This commit is contained in:
404
backend/routers/games.py
Normal file
404
backend/routers/games.py
Normal file
@@ -0,0 +1,404 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user