443 lines
13 KiB
Python
443 lines
13 KiB
Python
import asyncio
|
|
import uuid
|
|
from datetime import datetime
|
|
import logging
|
|
import random
|
|
|
|
from dataclasses import dataclass
|
|
from fastapi import WebSocket
|
|
from sqlalchemy.orm import Session
|
|
|
|
from game import (
|
|
GameState, CardInstance, PlayerState, action_play_card, action_sacrifice,
|
|
action_end_turn, create_game, CombatEvent, GameResult, BOARD_SIZE
|
|
)
|
|
from models import Card as CardModel, Deck as DeckModel, DeckCard as DeckCardModel, User as UserModel
|
|
from card import compute_deck_type
|
|
from ai 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
|
|
}
|
|
|
|
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:
|
|
pass
|
|
|
|
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)
|
|
|
|
|
|
## 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 Exception 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:
|
|
pass
|
|
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 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:
|
|
pass
|
|
|
|
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
|
|
|