🐐
This commit is contained in:
315
backend/services/trade_manager.py
Normal file
315
backend/services/trade_manager.py
Normal file
@@ -0,0 +1,315 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import WebSocket
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.models import Card as CardModel, DeckCard as DeckCardModel
|
||||
|
||||
logger = logging.getLogger("app")
|
||||
|
||||
## Card transfer
|
||||
|
||||
def transfer_cards(
|
||||
from_user_id: uuid.UUID,
|
||||
to_user_id: uuid.UUID,
|
||||
card_ids: list[uuid.UUID],
|
||||
db: Session,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
"""
|
||||
Reassigns card ownership, stamps received_at, removes deck memberships, and clears WTT.
|
||||
Does NOT commit — caller owns the transaction.
|
||||
Clearing WTT on transfer prevents a card from auto-appearing as tradeable on the new owner's
|
||||
profile without them explicitly opting in.
|
||||
"""
|
||||
if not card_ids:
|
||||
return
|
||||
|
||||
matched_cards = db.query(CardModel).filter(
|
||||
CardModel.id.in_(card_ids),
|
||||
CardModel.user_id == from_user_id,
|
||||
).all()
|
||||
|
||||
# Bail out if any card is missing or no longer owned by the sender — a partial
|
||||
# transfer would silently give the receiver fewer cards than agreed upon.
|
||||
if len(matched_cards) != len(card_ids):
|
||||
raise ValueError(
|
||||
f"Expected {len(card_ids)} cards owned by {from_user_id}, "
|
||||
f"found {len(matched_cards)}"
|
||||
)
|
||||
|
||||
for card in matched_cards:
|
||||
card.user_id = to_user_id
|
||||
card.received_at = now
|
||||
card.willing_to_trade = False
|
||||
db.query(DeckCardModel).filter(DeckCardModel.card_id == card.id).delete(synchronize_session=False)
|
||||
|
||||
|
||||
## Storage
|
||||
|
||||
@dataclass
|
||||
class TradeOffer:
|
||||
username: str
|
||||
cards: list[dict] = field(default_factory=list)
|
||||
accepted: bool = False
|
||||
|
||||
@dataclass
|
||||
class TradeSession:
|
||||
trade_id: str
|
||||
offers: dict[str, TradeOffer] # user_id -> TradeOffer
|
||||
connections: dict[str, WebSocket] = field(default_factory=dict)
|
||||
|
||||
active_trades: dict[str, TradeSession] = {}
|
||||
|
||||
@dataclass
|
||||
class TradeQueueEntry:
|
||||
user_id: str
|
||||
username: str
|
||||
websocket: WebSocket
|
||||
|
||||
trade_queue: list[TradeQueueEntry] = []
|
||||
trade_queue_lock = asyncio.Lock()
|
||||
|
||||
## Serialization
|
||||
|
||||
def serialize_card_model(card: CardModel) -> dict:
|
||||
return {
|
||||
"id": str(card.id),
|
||||
"name": card.name,
|
||||
"card_rarity": card.card_rarity,
|
||||
"card_type": card.card_type,
|
||||
"image_link": card.image_link,
|
||||
"attack": card.attack,
|
||||
"defense": card.defense,
|
||||
"cost": card.cost,
|
||||
"text": card.text,
|
||||
"generated_at": card.generated_at.isoformat() if card.generated_at else None,
|
||||
"received_at": card.received_at.isoformat() if card.received_at else None,
|
||||
"is_favorite": card.is_favorite,
|
||||
"willing_to_trade": card.willing_to_trade,
|
||||
}
|
||||
|
||||
def serialize_trade(session: TradeSession, perspective_user_id: str) -> dict:
|
||||
partner_id = next(uid for uid in session.offers if uid != perspective_user_id)
|
||||
my_offer = session.offers[perspective_user_id]
|
||||
their_offer = session.offers[partner_id]
|
||||
return {
|
||||
"trade_id": session.trade_id,
|
||||
"partner_username": their_offer.username,
|
||||
"my_offer": {
|
||||
"cards": my_offer.cards,
|
||||
"accepted": my_offer.accepted,
|
||||
},
|
||||
"their_offer": {
|
||||
"cards": their_offer.cards,
|
||||
"accepted": their_offer.accepted,
|
||||
},
|
||||
}
|
||||
|
||||
## Broadcasting
|
||||
|
||||
async def broadcast_trade(session: TradeSession) -> None:
|
||||
for user_id, ws in list(session.connections.items()):
|
||||
try:
|
||||
await ws.send_json({
|
||||
"type": "state",
|
||||
"state": serialize_trade(session, user_id),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||
|
||||
## Matchmaking
|
||||
|
||||
async def try_trade_match() -> None:
|
||||
async with trade_queue_lock:
|
||||
if len(trade_queue) < 2:
|
||||
return
|
||||
# Guard: same user queued twice
|
||||
if trade_queue[0].user_id == trade_queue[1].user_id:
|
||||
return
|
||||
p1 = trade_queue.pop(0)
|
||||
p2 = trade_queue.pop(0)
|
||||
|
||||
trade_id = str(uuid.uuid4())
|
||||
session = TradeSession(
|
||||
trade_id=trade_id,
|
||||
offers={
|
||||
p1.user_id: TradeOffer(username=p1.username),
|
||||
p2.user_id: TradeOffer(username=p2.username),
|
||||
},
|
||||
connections={
|
||||
p1.user_id: p1.websocket,
|
||||
p2.user_id: p2.websocket,
|
||||
},
|
||||
)
|
||||
active_trades[trade_id] = session
|
||||
|
||||
for entry in [p1, p2]:
|
||||
try:
|
||||
await entry.websocket.send_json({"type": "trade_start", "trade_id": trade_id})
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||
|
||||
## Action handling
|
||||
|
||||
async def handle_trade_action(
|
||||
trade_id: str,
|
||||
user_id: str,
|
||||
message: dict,
|
||||
db: Session,
|
||||
) -> None:
|
||||
session = active_trades.get(trade_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
action = message.get("type")
|
||||
ws = session.connections.get(user_id)
|
||||
|
||||
if action == "update_offer":
|
||||
card_ids = message.get("card_ids", [])
|
||||
|
||||
if card_ids:
|
||||
try:
|
||||
parsed_ids = [uuid.UUID(cid) for cid in card_ids]
|
||||
except ValueError:
|
||||
if ws:
|
||||
await ws.send_json({"type": "error", "message": "Invalid card IDs"})
|
||||
return
|
||||
|
||||
db_cards = db.query(CardModel).filter(
|
||||
CardModel.id.in_(parsed_ids),
|
||||
CardModel.user_id == uuid.UUID(user_id),
|
||||
).all()
|
||||
|
||||
if len(db_cards) != len(card_ids):
|
||||
if ws:
|
||||
await ws.send_json({"type": "error", "message": "Some cards are not in your collection"})
|
||||
return
|
||||
|
||||
# Preserve the order of card_ids
|
||||
card_map = {str(c.id): c for c in db_cards}
|
||||
ordered = [card_map[cid] for cid in card_ids if cid in card_map]
|
||||
session.offers[user_id].cards = [serialize_card_model(c) for c in ordered]
|
||||
else:
|
||||
session.offers[user_id].cards = []
|
||||
|
||||
# Any offer change unaccepts both sides
|
||||
for offer in session.offers.values():
|
||||
offer.accepted = False
|
||||
|
||||
await broadcast_trade(session)
|
||||
|
||||
elif action == "accept":
|
||||
either_has_cards = any(len(o.cards) > 0 for o in session.offers.values())
|
||||
if not either_has_cards:
|
||||
return
|
||||
|
||||
# Validate ownership of offered cards one more time
|
||||
my_offer = session.offers[user_id]
|
||||
if my_offer.cards:
|
||||
owned_count = db.query(CardModel).filter(
|
||||
CardModel.id.in_([uuid.UUID(c["id"]) for c in my_offer.cards]),
|
||||
CardModel.user_id == uuid.UUID(user_id),
|
||||
).count()
|
||||
if owned_count != len(my_offer.cards):
|
||||
if ws:
|
||||
await ws.send_json({"type": "error", "message": "Some offered cards are no longer in your collection"})
|
||||
return
|
||||
|
||||
my_offer.accepted = True
|
||||
|
||||
if all(o.accepted for o in session.offers.values()):
|
||||
await _complete_trade(trade_id, db)
|
||||
else:
|
||||
await broadcast_trade(session)
|
||||
|
||||
elif action == "unaccept":
|
||||
session.offers[user_id].accepted = False
|
||||
await broadcast_trade(session)
|
||||
|
||||
## Trade completion
|
||||
|
||||
async def _complete_trade(trade_id: str, db: Session) -> None:
|
||||
session = active_trades.get(trade_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
# Re-check that both sides are still accepted and have a non-empty offer.
|
||||
# A last-second unaccept or offer change (race or client bug) should abort.
|
||||
if not all(o.accepted for o in session.offers.values()):
|
||||
await broadcast_trade(session)
|
||||
return
|
||||
if not any(len(o.cards) > 0 for o in session.offers.values()):
|
||||
for offer in session.offers.values():
|
||||
offer.accepted = False
|
||||
await broadcast_trade(session)
|
||||
return
|
||||
|
||||
user_ids = list(session.offers.keys())
|
||||
u1, u2 = user_ids[0], user_ids[1]
|
||||
cards_u1 = session.offers[u1].cards # u1 gives these to u2
|
||||
cards_u2 = session.offers[u2].cards # u2 gives these to u1
|
||||
|
||||
# Final ownership double-check before writing
|
||||
def verify(from_id: str, card_dicts: list[dict]) -> bool:
|
||||
if not card_dicts:
|
||||
return True
|
||||
card_uuids = [uuid.UUID(c["id"]) for c in card_dicts]
|
||||
count = db.query(CardModel).filter(
|
||||
CardModel.id.in_(card_uuids),
|
||||
CardModel.user_id == uuid.UUID(from_id),
|
||||
).count()
|
||||
return count == len(card_uuids)
|
||||
|
||||
if not verify(u1, cards_u1) or not verify(u2, cards_u2):
|
||||
db.rollback()
|
||||
for ws in list(session.connections.values()):
|
||||
try:
|
||||
await ws.send_json({
|
||||
"type": "error",
|
||||
"message": "Trade failed: ownership check failed. Offers have been reset.",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||
for offer in session.offers.values():
|
||||
offer.accepted = False
|
||||
await broadcast_trade(session)
|
||||
return
|
||||
|
||||
# Transfer ownership and clear deck relationships
|
||||
now = datetime.now()
|
||||
transfer_cards(uuid.UUID(u1), uuid.UUID(u2), [uuid.UUID(c["id"]) for c in cards_u1], db, now)
|
||||
transfer_cards(uuid.UUID(u2), uuid.UUID(u1), [uuid.UUID(c["id"]) for c in cards_u2], db, now)
|
||||
db.commit()
|
||||
|
||||
active_trades.pop(trade_id, None)
|
||||
|
||||
for ws in list(session.connections.values()):
|
||||
try:
|
||||
await ws.send_json({"type": "trade_complete"})
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||
|
||||
## Disconnect handling
|
||||
|
||||
async def handle_trade_disconnect(trade_id: str, user_id: str) -> None:
|
||||
session = active_trades.get(trade_id)
|
||||
if not session:
|
||||
return
|
||||
|
||||
active_trades.pop(trade_id, None)
|
||||
|
||||
for uid, ws in list(session.connections.items()):
|
||||
if uid == user_id:
|
||||
continue
|
||||
try:
|
||||
await ws.send_json({
|
||||
"type": "error",
|
||||
"message": "Your trade partner disconnected. Trade cancelled.",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||
Reference in New Issue
Block a user