feat: implement chest opening core logic with unique checks and audit
This commit is contained in:
@@ -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():
|
||||
|
||||
141
backend/app/routers/chests.py
Normal file
141
backend/app/routers/chests.py
Normal file
@@ -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)}")
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user