298 lines
7.8 KiB
Python
298 lines
7.8 KiB
Python
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
import random
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
from 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
|
|
|
|
@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
|
|
)
|
|
|
|
@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 |