import asyncio import logging import random import uuid from dataclasses import dataclass from datetime import datetime from fastapi import WebSocket from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from game.rules import ( GameState, CardInstance, PlayerState, action_play_card, action_sacrifice, action_end_turn, create_game, CombatEvent, GameResult, BOARD_SIZE ) from core.models import Card as CardModel, Deck as DeckModel, DeckCard as DeckCardModel, User as UserModel from game.card import compute_deck_type from ai.engine import AI_USER_ID, run_ai_turn, get_random_personality, choose_cards logger = logging.getLogger("app") ## Storage active_games: dict[str, GameState] = {} active_deck_ids: dict[str, str|None] = {} # user_id -> deck_id connections: dict[str, dict[str, WebSocket]] = {} # game_id -> {user_id -> websocket} @dataclass class QueueEntry: user_id: str deck_id: str websocket: WebSocket queue: list[QueueEntry] = [] queue_lock = asyncio.Lock() ## Game Result def record_game_result(state: GameState, db: Session): if state.result is None or state.result.winner_id is None: return winner_id_str = state.result.winner_id loser_id_str = state.opponent_id(winner_id_str) # Skip database updates for AI battles if AI_USER_ID not in [winner_id_str, loser_id_str]: winner = db.query(UserModel).filter(UserModel.id == uuid.UUID(winner_id_str)).first() if winner: winner.wins += 1 loser = db.query(UserModel).filter(UserModel.id == uuid.UUID(loser_id_str)).first() if loser: loser.losses += 1 winner_deck_id = active_deck_ids.get(winner_id_str) loser_deck_id = active_deck_ids.get(loser_id_str) if AI_USER_ID not in [winner_id_str, loser_id_str]: if winner_deck_id: deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(winner_deck_id)).first() if deck: deck.times_played += 1 deck.wins += 1 else: logger.warning(f"record_game_result: no deck_id found for winner {winner_id_str}") if loser_deck_id: deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(loser_deck_id)).first() if deck: deck.times_played += 1 deck.losses += 1 else: logger.warning(f"record_game_result: no deck_id found for loser {loser_id_str}") db.commit() ## Serialization def serialize_card(card: CardInstance|None) -> dict | None: if card is None: return None return { "instance_id": card.instance_id, "card_id": card.card_id, "name": card.name, "attack": card.attack, "defense": card.defense, "max_defense": card.max_defense, "cost": card.cost, "card_type": card.card_type, "card_rarity": card.card_rarity, "image_link": card.image_link, "text": card.text, "is_favorite": card.is_favorite, "willing_to_trade": card.willing_to_trade, } def serialize_player(player: PlayerState, hide_hand=False) -> dict: return { "user_id": player.user_id, "username": player.username, "deck_type": player.deck_type, "life": player.life, "energy": player.energy, "energy_cap": player.energy_cap, "board": [serialize_card(c) for c in player.board], "hand": [serialize_card(c) for c in player.hand] if not hide_hand else [], "hand_size": len(player.hand), "deck_size": len(player.deck), } def serialize_event(event: CombatEvent) -> dict: return { "attacker_slot": event.attacker_slot, "attacker_name": event.attacker_name, "defender_slot": event.defender_slot, "defender_name": event.defender_name, "damage": event.damage, "defender_destroyed": event.defender_destroyed, "life_damage": event.life_damage, } def serialize_state(state: GameState, perspective_user_id: str) -> dict: opponent_id = state.opponent_id(perspective_user_id) return { "game_id": state.game_id, "phase": state.phase, "turn": state.turn, "active_player_id": state.active_player_id, "player_order": state.player_order, "you": serialize_player(state.players[perspective_user_id]), "opponent": serialize_player(state.players[opponent_id], hide_hand=True), "last_combat_events": [serialize_event(e) for e in state.last_combat_events], "result": { "winner_id": state.result.winner_id, "reason": state.result.reason, } if state.result else None, "turn_started_at": state.turn_started_at.isoformat() if state.turn_started_at else None, } ## Broadcasting async def broadcast_state(game_id: str): state = active_games.get(game_id) if not state: return game_connections = connections.get(game_id, {}) for user_id, ws in game_connections.items(): try: await ws.send_json({ "type": "state", "state": serialize_state(state, user_id), }) except Exception as e: logger.debug(f"WebSocket send failed (stale connection): {e}") if state.active_player_id == AI_USER_ID and not state.result: asyncio.create_task(run_ai_turn(game_id)) async def send_error(ws: WebSocket, message: str): await ws.send_json({"type": "error", "message": message}) ## Matchmaking def load_deck_cards(deck_id: str, user_id: str, db: Session) -> list | None: deck = db.query(DeckModel).filter( DeckModel.id == uuid.UUID(deck_id), DeckModel.user_id == uuid.UUID(user_id) ).first() if not deck: return None deck_card_ids = [ dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all() ] cards = db.query(CardModel).filter(CardModel.id.in_(deck_card_ids)).all() return cards async def try_match(db: Session): async with queue_lock: if len(queue) < 2: return p1_entry = queue.pop(0) p2_entry = queue.pop(0) p1_user = db.query(UserModel).filter(UserModel.id == uuid.UUID(p1_entry.user_id)).first() p2_user = db.query(UserModel).filter(UserModel.id == uuid.UUID(p2_entry.user_id)).first() p1_cards = load_deck_cards(p1_entry.deck_id, p1_entry.user_id, db) p2_cards = load_deck_cards(p2_entry.deck_id, p2_entry.user_id, db) p1_deck_type = compute_deck_type(p1_cards if p1_cards else []) p2_deck_type = compute_deck_type(p2_cards if p2_cards else []) active_deck_ids[p1_entry.user_id] = p1_entry.deck_id active_deck_ids[p2_entry.user_id] = p2_entry.deck_id if not p1_cards or not p2_cards or not p1_user or not p2_user: await send_error(p1_entry.websocket, "Failed to load deck") await send_error(p2_entry.websocket, "Failed to load deck") return state = create_game( p1_entry.user_id, p1_user.username, p1_deck_type if p1_deck_type else "", p1_cards, p2_entry.user_id, p2_user.username, p2_deck_type if p2_deck_type else "", p2_cards, ) active_games[state.game_id] = state connections[state.game_id] = { p1_entry.user_id: p1_entry.websocket, p2_entry.user_id: p2_entry.websocket, } # Notify both players the game has started for user_id, ws in connections[state.game_id].items(): await ws.send_json({ "type": "game_start", "game_id": state.game_id, }) await broadcast_state(state.game_id) ## Direct challenge game creation (no WebSocket needed at creation time) def create_challenge_game( challenger_id: str, challenger_deck_id: str, challenged_id: str, challenged_deck_id: str, db: Session ) -> str: challenger = db.query(UserModel).filter(UserModel.id == uuid.UUID(challenger_id)).first() challenged = db.query(UserModel).filter(UserModel.id == uuid.UUID(challenged_id)).first() p1_cards = load_deck_cards(challenger_deck_id, challenger_id, db) p2_cards = load_deck_cards(challenged_deck_id, challenged_id, db) if not p1_cards or not p2_cards or not challenger or not challenged: raise ValueError("Could not load decks or players") p1_deck_type = compute_deck_type(p1_cards) p2_deck_type = compute_deck_type(p2_cards) state = create_game( challenger_id, challenger.username, p1_deck_type or "", p1_cards, challenged_id, challenged.username, p2_deck_type or "", p2_cards, ) active_games[state.game_id] = state # Initialize with no websockets; players connect via /ws/game/{game_id} after redirect connections[state.game_id] = {challenger_id: None, challenged_id: None} active_deck_ids[challenger_id] = challenger_deck_id active_deck_ids[challenged_id] = challenged_deck_id return state.game_id ## Action handler async def handle_action(game_id: str, user_id: str, message: dict, db: Session): state = active_games.get(game_id) if not state: logger.warning(f"handle_action: game {game_id} not found") return if state.result: logger.warning(f"handle_action: game {game_id} already over") return if state.active_player_id != user_id: logger.warning(f"handle_action: not {user_id}'s turn, active is {state.active_player_id}") ws = connections[game_id].get(user_id) if ws: await send_error(ws, "It's not your turn") return action = message.get("type") err = None if action == "play_card": err = action_play_card(state, message["hand_index"], message["slot"]) if not err: # Find the card that was just played slot = message["slot"] card_instance = state.players[user_id].board[slot] if card_instance: try: card = db.query(CardModel).filter( CardModel.id == uuid.UUID(card_instance.card_id) ).first() if card: card.times_played += 1 db.commit() except (SQLAlchemyError, ValueError) as e: logger.warning(f"Failed to increment times_played for card {card_instance.card_id}: {e}") db.rollback() elif action == "sacrifice": slot = message.get("slot") if slot is None: err = "No slot provided" else: # Find the card instance_id before it's removed card = state.players[user_id].board[slot] if card: # Notify opponent first opponent_id = state.opponent_id(user_id) opp_ws = connections[game_id].get(opponent_id) if opp_ws: try: await opp_ws.send_json({ "type": "sacrifice_animation", "instance_id": card.instance_id, }) except Exception as e: logger.debug(f"WebSocket send failed (stale connection): {e}") await asyncio.sleep(0.65) err = action_sacrifice(state, slot) elif action == "end_turn": err = action_end_turn(state) else: ws = connections[game_id].get(user_id) if ws: await send_error(ws, f"Unknown action: {action}") return if err: ws = connections[game_id].get(user_id) if ws: await send_error(ws, err) return await broadcast_state(game_id) if state.result: record_game_result(state, db) for uid in list(connections.get(game_id,{}).keys()): active_deck_ids.pop(uid, None) active_games.pop(game_id, None) connections.pop(game_id, None) DISCONNECT_GRACE_SECONDS = 15 async def handle_disconnect(game_id: str, user_id: str): await asyncio.sleep(DISCONNECT_GRACE_SECONDS) # Check if game still exists and player hasn't reconnected if game_id not in active_games: return if user_id in connections.get(game_id, {}): return # player reconnected during grace period state = active_games[game_id] if state.result: return # game already ended normally winner_id = state.opponent_id(user_id) state.result = GameResult( winner_id=winner_id, reason="Opponent disconnected" ) state.phase = "end" from core.database import SessionLocal db = SessionLocal() try: record_game_result(state, db) finally: db.close() # Notify the remaining player winner_ws = connections[game_id].get(winner_id) if winner_ws: try: await winner_ws.send_json({ "type": "state", "state": serialize_state(state, winner_id), }) except Exception as e: logger.debug(f"WebSocket send failed (stale connection): {e}") active_deck_ids.pop(user_id, None) active_deck_ids.pop(winner_id, None) active_games.pop(game_id, None) connections.pop(game_id, None) TURN_TIME_LIMIT_SECONDS = 120 async def handle_timeout_claim(game_id: str, claimant_id: str, db: Session) -> str | None: state = active_games.get(game_id) if not state: return "Game not found" if state.result: return "Game already ended" if state.active_player_id == claimant_id: return "It's your turn" if not state.turn_started_at: return "No turn timer running" elapsed = (datetime.now() - state.turn_started_at).total_seconds() if elapsed < TURN_TIME_LIMIT_SECONDS: return f"Timer has not expired yet ({int(TURN_TIME_LIMIT_SECONDS - elapsed)}s remaining)" state.result = GameResult( winner_id=claimant_id, reason="Opponent ran out of time" ) state.phase = "end" record_game_result(state, db) await broadcast_state(game_id) active_deck_ids.pop(state.active_player_id, None) active_deck_ids.pop(claimant_id, None) active_games.pop(game_id, None) connections.pop(game_id, None) return None def create_solo_game( user_id: str, username: str, player_cards: list, ai_cards: list, deck_id: str, difficulty: int = 5, ) -> str: ai_personality = get_random_personality() ai_deck = choose_cards(ai_cards, difficulty, ai_personality) player_deck_type = compute_deck_type(player_cards) or "Balanced" ai_deck_type = compute_deck_type(ai_deck) or "Balanced" state = create_game( user_id, username, player_deck_type, player_cards, AI_USER_ID, "Computer", ai_deck_type, ai_deck, ) state.ai_difficulty = difficulty state.ai_personality = ai_personality.value active_games[state.game_id] = state connections[state.game_id] = {} active_deck_ids[user_id] = deck_id active_deck_ids[AI_USER_ID] = None if state.active_player_id == AI_USER_ID: asyncio.create_task(run_ai_turn(state.game_id)) return state.game_id ANIMATION_DELAYS = { "pre_combat_pause": 0.5, # pause before end_turn "per_attack_pre": 0.1, # delay before each attack animation "lunge_duration": 0.42, # lunge animation duration "shake_duration": 0.4, # shake animation duration "damage_point": 0.22, # when damage is applied mid-shake "post_attack": 0.08, # gap between attacks "destroy_duration": 0.6, # crumble animation duration "post_combat_buffer": 0.3, # buffer after all animations finish } def calculate_combat_animation_time(events: list[CombatEvent]) -> float: total = 0.0 for event in events: total += ANIMATION_DELAYS["per_attack_pre"] # Lunge and shake run simultaneously, so take the longer of the two total += max( ANIMATION_DELAYS["lunge_duration"], ANIMATION_DELAYS["shake_duration"] ) total += ANIMATION_DELAYS["post_attack"] if event.defender_destroyed: total += ANIMATION_DELAYS["destroy_duration"] total += ANIMATION_DELAYS["post_combat_buffer"] return total