import random import uuid from dataclasses import dataclass, field from datetime import datetime from typing import Optional from core.models import Card as CardModel STARTING_LIFE = 1000 MAX_ENERGY_CAP = 6 BOARD_SIZE = 5 HAND_SIZE = 5 @dataclass class CardInstance: instance_id: str card_id: str name: str attack: int defense: int max_defense: int cost: int card_type: str card_rarity: str image_link: str text: str is_favorite: bool = False willing_to_trade: bool = False @classmethod def from_db_card(cls, card: CardModel) -> "CardInstance": return cls( instance_id=str(uuid.uuid4()), card_id=str(card.id), name=card.name, attack=card.attack, defense=card.defense, max_defense=card.defense, cost=card.cost, card_type=card.card_type, card_rarity=card.card_rarity, image_link=card.image_link or "", text=card.text, is_favorite=card.is_favorite, willing_to_trade=card.willing_to_trade, ) @dataclass class PlayerState: user_id: str username: str deck_type: str life: int = STARTING_LIFE hand: list[CardInstance] = field(default_factory=list) deck: list[CardInstance] = field(default_factory=list) board: list[Optional[CardInstance]] = field(default_factory=lambda: [None] * BOARD_SIZE) energy: int = 0 energy_cap: int = 0 def draw_to_full(self): """Draw cards until hand has HAND_SIZE cards or deck is empty.""" while len(self.hand) < HAND_SIZE and self.deck: self.hand.append(self.deck.pop()) def refill_energy(self): self.energy = self.energy_cap def increment_energy_cap(self): self.energy_cap = min(self.energy_cap + 1, MAX_ENERGY_CAP) def has_playable_cards(self) -> bool: """True if the player has any playable cards left in deck or hand or on board.""" board_empty = all([c is None for c in self.board]) non_played_cards = self.deck + self.hand return (not board_empty) or any(c.cost <= max(MAX_ENERGY_CAP,self.energy) for c in non_played_cards) @dataclass class CombatEvent: attacker_slot: int attacker_name: str defender_slot: Optional[int] # None if attacking life defender_name: Optional[str] damage: int defender_destroyed: bool life_damage: int # > 0 if hitting life total @dataclass class GameResult: winner_id: Optional[str] # None if still ongoing reason: Optional[str] @dataclass class GameState: game_id: str players: dict[str, PlayerState] player_order: list[str] # [player1_id, player2_id] active_player_id: str phase: str # "main" | "combat" | "end" turn: int = 1 result: Optional[GameResult] = None last_combat_events: list[CombatEvent] = field(default_factory=list) turn_started_at: Optional[datetime] = None ai_difficulty: int = 5 # 1-10, only used for AI games ai_personality: Optional[str] = None # AI personality type, only used for AI games def opponent_id(self, player_id: str) -> str: return next(p for p in self.player_order if p != player_id) def active_player(self) -> PlayerState: return self.players[self.active_player_id] def opponent(self) -> PlayerState: return self.players[self.opponent_id(self.active_player_id)] def create_game( player1_id: str, player1_username: str, player1_deck_type: str, player1_cards: list, player2_id: str, player2_username: str, player2_deck_type: str, player2_cards: list, ) -> GameState: def make_player(user_id, username, deck_type, cards): deck = [CardInstance.from_db_card(c) for c in cards] random.shuffle(deck) player = PlayerState(user_id=user_id, username=username, deck_type=deck_type, deck=deck) return player p1 = make_player(player1_id, player1_username, player1_deck_type, player1_cards) p2 = make_player(player2_id, player2_username, player2_deck_type, player2_cards) # Randomly decide who goes first order = [player1_id, player2_id] random.shuffle(order) first = order[0] # First player starts with energy cap 1, draw immediately p1.increment_energy_cap() p2.increment_energy_cap() players = {player1_id: p1, player2_id: p2} players[first].refill_energy() players[first].draw_to_full() state = GameState( game_id=str(uuid.uuid4()), players=players, player_order=order, active_player_id=first, phase="main", turn=1, ) state.turn_started_at = datetime.now() return state def check_win_condition(state: GameState) -> Optional[GameResult]: for pid, player in state.players.items(): if player.life <= 0: return GameResult( winner_id=state.opponent_id(pid), reason="Life reduced to zero" ) for pid, player in state.players.items(): opp = state.players[state.opponent_id(pid)] if any(c for c in player.board if c) and not opp.has_playable_cards(): return GameResult( winner_id=pid, reason="Opponent has no playable cards remaining" ) return None def resolve_combat(state: GameState) -> list[CombatEvent]: active = state.active_player() opponent = state.opponent() events = [] for slot in range(BOARD_SIZE): attacker = active.board[slot] if attacker is None: continue defender = opponent.board[slot] if defender is None: # Direct life damage opponent.life -= attacker.attack events.append(CombatEvent( attacker_slot=slot, attacker_name=attacker.name, defender_slot=None, defender_name=None, damage=attacker.attack, defender_destroyed=False, life_damage=attacker.attack, )) else: # Attack the opposing card defender.defense -= attacker.attack destroyed = defender.defense <= 0 if destroyed: opponent.board[slot] = None events.append(CombatEvent( attacker_slot=slot, attacker_name=attacker.name, defender_slot=slot, defender_name=defender.name, damage=attacker.attack, defender_destroyed=destroyed, life_damage=0, )) return events def action_play_card(state: GameState, hand_index: int, slot: int) -> str | None: if state.phase != "main": return "Not in main phase" player = state.active_player() if slot < 0 or slot >= BOARD_SIZE: return f"Invalid slot {slot}" if hand_index < 0 or hand_index >= len(player.hand): return "Invalid hand index" if player.board[slot] is not None: return "Slot already occupied" card = player.hand[hand_index] if card.cost > player.energy: return f"Not enough energy (have {player.energy}, need {card.cost})" player.energy -= card.cost player.board[slot] = card player.hand.pop(hand_index) return None def action_sacrifice(state: GameState, slot: int) -> str | None: if state.phase != "main": return "Not in main phase" player = state.active_player() if slot < 0 or slot >= BOARD_SIZE: return f"Invalid slot {slot}" card = player.board[slot] if card is None: return "No card in that slot" # player.energy += card.cost player.energy += 1 player.board[slot] = None return None def action_end_turn(state: GameState) -> str | None: if state.phase != "main": return "Not in main phase" state.phase = "combat" # Resolve combat events = resolve_combat(state) state.last_combat_events = events # Check win condition after combat result = check_win_condition(state) if result: state.result = result state.phase = "end" return None # Switch to next player next_player_id = state.opponent_id(state.active_player_id) state.active_player_id = next_player_id state.turn += 1 next_player = state.active_player() next_player.increment_energy_cap() next_player.refill_energy() next_player.draw_to_full() # Check if next player can't do anything result = check_win_condition(state) if result: state.result = result state.phase = "end" return None state.phase = "main" state.turn_started_at = datetime.now() return None