Files
wiki-tcg/backend/game.py
Nikolaj 0de769284c 🐐
2026-03-19 14:53:33 +01:00

297 lines
7.7 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 = 500
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(0))
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_ENERGY_CAP 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.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