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

404
backend/routers/games.py Normal file
View 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}