From 26c629d6052b5da3572b10db6af5e1d65f602e16 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 9 Feb 2026 14:45:25 +0100 Subject: [PATCH] feat: implement chest opening core logic with unique checks and audit --- backend/app/main.py | 3 +- backend/app/routers/chests.py | 141 ++++++++++++++++++++++++++++++++++ backend/app/schemas.py | 13 ++++ 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 backend/app/routers/chests.py diff --git a/backend/app/main.py b/backend/app/main.py index 4b6b4a8..07b28fe 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import Session from sqlalchemy import text from app.database import get_db, engine from app import models, seed -from app.routers import auth, users +from app.routers import auth, users, chests # Create all tables models.Base.metadata.create_all(bind=engine) @@ -12,6 +12,7 @@ app = FastAPI(title="Card Game Backend") app.include_router(auth.router) app.include_router(users.router) +app.include_router(chests.router) @app.on_event("startup") def startup_event(): diff --git a/backend/app/routers/chests.py b/backend/app/routers/chests.py new file mode 100644 index 0000000..c7efb17 --- /dev/null +++ b/backend/app/routers/chests.py @@ -0,0 +1,141 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from app import models, dependencies, schemas +from app.database import get_db +import random + +router = APIRouter(prefix="/chests", tags=["chests"]) + +DUPLICATE_CONVERSION_GOLD = 20 +RARITY_WEIGHTS = { + "COMMON": 70, + "RARE": 25, + "LEGENDARY": 5 +} + +def roll_rarity(): + rand = random.randint(1, 100) + if rand <= 70: + return "COMMON" + elif rand <= 95: + return "RARE" + else: + return "LEGENDARY" + +@router.post("/{chest_id}/open", response_model=schemas.ChestOpenResponse) +def open_chest(chest_id: str, current_user: models.User = Depends(dependencies.get_current_user), db: Session = Depends(get_db)): + # 1. Fetch Chest + chest = db.query(models.Chest).filter(models.Chest.id == chest_id).first() + if not chest: + raise HTTPException(status_code=404, detail="Chest not found") + + # 2. Check Balance + if not current_user.wallet or current_user.wallet.balance < chest.cost_gold: + raise HTTPException(status_code=403, detail="Insufficient gold") + + # 3. Start Logic (Implicit transaction via Session) + try: + # Debit Wallet + current_user.wallet.balance -= chest.cost_gold + db.add(current_user.wallet) + + # Log Debit Transaction + debit_tx = models.WalletTransaction( + user_id=current_user.id, + type="DEBIT", + amount=chest.cost_gold, + reason="CHEST_OPEN" + ) + db.add(debit_tx) + + # Flush to get IDs if needed, but not strictly necessary yet. + # Using flush helps catch DB errors early. + db.flush() + + # Create ChestOpen Audit + chest_open = models.ChestOpen( + user_id=current_user.id, + chest_id=chest.id, + spent_gold=chest.cost_gold + ) + db.add(chest_open) + db.flush() # Get chest_open.id + + items_response = [] + + # Track cards obtained in this transaction to prevent valid duplicates within the same chest + cards_obtained_ids = set() + + # 4. Roll Cards + for _ in range(chest.cards_per_open): + rarity = roll_rarity() + + # Fetch all cards of this rarity + potential_cards = db.query(models.Card).filter(models.Card.rarity == rarity).all() + + if not potential_cards: + # Should not happen in seeded DB, but handle safely + outcome = "CONVERTED" + card = None + converted_gold = DUPLICATE_CONVERSION_GOLD + else: + card = random.choice(potential_cards) + + # Check if user owns it (DB check + local transaction check) + user_owned = db.query(models.UserCard).filter( + models.UserCard.user_id == current_user.id, + models.UserCard.card_id == card.id + ).first() + + if user_owned or card.id in cards_obtained_ids: + outcome = "CONVERTED" + converted_gold = DUPLICATE_CONVERSION_GOLD + else: + outcome = "NEW" + converted_gold = None + # Add to collection + new_user_card = models.UserCard(user_id=current_user.id, card_id=card.id) + db.add(new_user_card) + cards_obtained_ids.add(card.id) + + # Handle Conversion Credit + if outcome == "CONVERTED": + current_user.wallet.balance += converted_gold + credit_tx = models.WalletTransaction( + user_id=current_user.id, + type="CREDIT", + amount=converted_gold, + reason="DUPLICATE_CONVERT", + reference_id=chest_open.id + ) + db.add(credit_tx) + + # Log Item + open_item = models.ChestOpenItem( + chest_open_id=chest_open.id, + card_id=card.id if card else None, + rarity=rarity, + outcome_type=outcome, + converted_gold=converted_gold + ) + db.add(open_item) + + items_response.append({ + "card_name": card.name if card else None, + "rarity": rarity, + "outcome": outcome, + "converted_gold": converted_gold + }) + + db.commit() + db.refresh(current_user.wallet) + + return { + "chest_open_id": chest_open.id, + "spent_gold": chest.cost_gold, + "items": items_response + } + + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"Transaction failed: {str(e)}") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 8bcf398..7fb463c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -20,3 +20,16 @@ class UserResponse(BaseModel): class Config: orm_mode = True + +from typing import List, Optional + +class ChestOpenItemResponse(BaseModel): + card_name: Optional[str] = None + rarity: str + outcome: str # NEW / CONVERTED + converted_gold: Optional[int] = None + +class ChestOpenResponse(BaseModel): + chest_open_id: str + spent_gold: int + items: List[ChestOpenItemResponse]