Files
wiki-tcg/backend/services/trade_manager.py
2026-04-01 18:31:33 +02:00

316 lines
9.1 KiB
Python

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}")