🐐
This commit is contained in:
302
backend/game/rules.py
Normal file
302
backend/game/rules.py
Normal file
@@ -0,0 +1,302 @@
|
||||
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
|
||||
Reference in New Issue
Block a user