457 lines
15 KiB
Python
457 lines
15 KiB
Python
import uuid
|
|
|
|
from dotenv import load_dotenv
|
|
load_dotenv()
|
|
|
|
from game.rules import (
|
|
GameState, PlayerState, CardInstance, CombatEvent, GameResult,
|
|
create_game, resolve_combat, check_win_condition,
|
|
action_play_card, action_sacrifice, action_end_turn,
|
|
BOARD_SIZE, HAND_SIZE, STARTING_LIFE, MAX_ENERGY_CAP,
|
|
)
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
def make_card(name="Test Card", attack=10, defense=10, cost=2, **kwargs) -> CardInstance:
|
|
return CardInstance(
|
|
instance_id=str(uuid.uuid4()),
|
|
card_id=str(uuid.uuid4()),
|
|
name=name,
|
|
attack=attack,
|
|
defense=defense,
|
|
max_defense=defense,
|
|
cost=cost,
|
|
card_type="other",
|
|
card_rarity="common",
|
|
image_link="",
|
|
text="",
|
|
**kwargs,
|
|
)
|
|
|
|
def make_game(
|
|
p1_board=None, p2_board=None,
|
|
p1_hand=None, p2_hand=None,
|
|
p1_deck=None, p2_deck=None,
|
|
p1_life=STARTING_LIFE, p2_life=STARTING_LIFE,
|
|
p1_energy=6, p2_energy=6,
|
|
) -> GameState:
|
|
p1 = PlayerState(
|
|
user_id="p1",
|
|
username="player 1",
|
|
deck_type="test",
|
|
life=p1_life,
|
|
hand=p1_hand or [],
|
|
deck=p1_deck or [],
|
|
board=p1_board or [None] * BOARD_SIZE,
|
|
energy=p1_energy,
|
|
energy_cap=6,
|
|
)
|
|
p2 = PlayerState(
|
|
user_id="p2",
|
|
username="player 2",
|
|
deck_type="test",
|
|
life=p2_life,
|
|
hand=p2_hand or [],
|
|
deck=p2_deck or [],
|
|
board=p2_board or [None] * BOARD_SIZE,
|
|
energy=p2_energy,
|
|
energy_cap=6,
|
|
)
|
|
return GameState(
|
|
game_id="test-game",
|
|
players={"p1": p1, "p2": p2},
|
|
player_order=["p1", "p2"],
|
|
active_player_id="p1",
|
|
phase="main",
|
|
)
|
|
|
|
|
|
# ── create_game ───────────────────────────────────────────────────────────────
|
|
|
|
class TestCreateGame:
|
|
def test_both_players_present(self):
|
|
class FakeCard:
|
|
id = uuid.uuid4()
|
|
name = "Card"
|
|
attack = 5
|
|
defense = 5
|
|
cost = 1
|
|
card_type = "other"
|
|
card_rarity = "common"
|
|
image_link = ""
|
|
text = ""
|
|
is_favorite = False
|
|
willing_to_trade = False
|
|
|
|
cards = [FakeCard() for _ in range(20)]
|
|
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
|
|
assert "p1" in state.players
|
|
assert "p2" in state.players
|
|
|
|
def test_first_player_has_drawn(self):
|
|
class FakeCard:
|
|
id = uuid.uuid4()
|
|
name = "Card"
|
|
attack = 5
|
|
defense = 5
|
|
cost = 1
|
|
card_type = "other"
|
|
card_rarity = "common"
|
|
image_link = ""
|
|
text = ""
|
|
is_favorite = False
|
|
willing_to_trade = False
|
|
|
|
cards = [FakeCard() for _ in range(20)]
|
|
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
|
|
first = state.players[state.active_player_id]
|
|
assert len(first.hand) == HAND_SIZE
|
|
|
|
def test_second_player_has_not_drawn(self):
|
|
class FakeCard:
|
|
id = uuid.uuid4()
|
|
name = "Card"
|
|
attack = 5
|
|
defense = 5
|
|
cost = 1
|
|
card_type = "other"
|
|
card_rarity = "common"
|
|
image_link = ""
|
|
text = ""
|
|
is_favorite = False
|
|
willing_to_trade = False
|
|
|
|
cards = [FakeCard() for _ in range(20)]
|
|
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
|
|
second_id = state.opponent_id(state.active_player_id)
|
|
second = state.players[second_id]
|
|
assert len(second.hand) == 0
|
|
|
|
def test_first_player_starts_with_energy(self):
|
|
class FakeCard:
|
|
id = uuid.uuid4()
|
|
name = "Card"
|
|
attack = 5
|
|
defense = 5
|
|
cost = 1
|
|
card_type = "other"
|
|
card_rarity = "common"
|
|
image_link = ""
|
|
text = ""
|
|
is_favorite = False
|
|
willing_to_trade = False
|
|
|
|
cards = [FakeCard() for _ in range(20)]
|
|
state = create_game("p1", "player 1", "test", cards, "p2", "player 2", "test", cards)
|
|
first = state.players[state.active_player_id]
|
|
opp = state.players[state.opponent_id(state.active_player_id)]
|
|
assert first.energy == 1
|
|
assert first.energy_cap == 1
|
|
assert opp.energy == 0
|
|
assert opp.energy_cap == 1
|
|
|
|
|
|
# ── action_play_card ──────────────────────────────────────────────────────────
|
|
|
|
class TestPlayCard:
|
|
def test_play_card_successfully(self):
|
|
card = make_card(cost=2)
|
|
state = make_game(p1_hand=[card], p1_energy=3)
|
|
err = action_play_card(state, hand_index=0, slot=0)
|
|
assert err is None
|
|
assert state.players["p1"].board[0] is card
|
|
assert state.players["p1"].energy == 1
|
|
assert card not in state.players["p1"].hand
|
|
|
|
def test_play_card_multiple(self):
|
|
card1 = make_card(cost=2)
|
|
card2 = make_card(cost=1)
|
|
state = make_game(p1_hand=[card1,card2], p1_energy=3)
|
|
err1 = action_play_card(state, hand_index=0, slot=0)
|
|
err2 = action_play_card(state, hand_index=0, slot=1)
|
|
assert err1 is None
|
|
assert err2 is None
|
|
assert state.players["p1"].board[0] is card1
|
|
assert state.players["p1"].board[1] is card2
|
|
assert state.players["p1"].energy == 0
|
|
assert card1 not in state.players["p1"].hand
|
|
assert card2 not in state.players["p1"].hand
|
|
|
|
def test_play_card_not_enough_energy(self):
|
|
card = make_card(cost=5)
|
|
state = make_game(p1_hand=[card], p1_energy=2)
|
|
err = action_play_card(state, hand_index=0, slot=0)
|
|
assert err is not None
|
|
assert err == "Not enough energy (have 2, need 5)"
|
|
assert state.players["p1"].board[0] is None
|
|
|
|
def test_play_card_slot_occupied(self):
|
|
existing = make_card(name="Existing")
|
|
new_card = make_card(name="New")
|
|
board = [existing] + [None] * (BOARD_SIZE - 1)
|
|
state = make_game(p1_board=board, p1_hand=[new_card], p1_energy=6)
|
|
err = action_play_card(state, hand_index=0, slot=0)
|
|
assert err is not None
|
|
assert err == "Slot already occupied"
|
|
assert state.players["p1"].board[0] is existing
|
|
|
|
def test_play_card_invalid_slot(self):
|
|
card = make_card()
|
|
state = make_game(p1_hand=[card], p1_energy=6)
|
|
err = action_play_card(state, hand_index=0, slot=99)
|
|
assert err is not None
|
|
assert err == "Invalid slot 99"
|
|
|
|
def test_play_card_invalid_hand_index(self):
|
|
state = make_game(p1_hand=[], p1_energy=6)
|
|
err = action_play_card(state, hand_index=0, slot=0)
|
|
assert err is not None
|
|
assert err == "Invalid hand index"
|
|
|
|
def test_play_card_wrong_phase(self):
|
|
card = make_card()
|
|
state = make_game(p1_hand=[card], p1_energy=6)
|
|
state.phase = "combat"
|
|
err = action_play_card(state, hand_index=0, slot=0)
|
|
assert err is not None
|
|
assert err == "Not in main phase"
|
|
|
|
def test_play_card_exact_energy(self):
|
|
card = make_card(cost=3)
|
|
state = make_game(p1_hand=[card], p1_energy=3)
|
|
err = action_play_card(state, hand_index=0, slot=0)
|
|
assert err is None
|
|
assert state.players["p1"].energy == 0
|
|
|
|
|
|
# ── action_sacrifice ──────────────────────────────────────────────────────────
|
|
|
|
class TestSacrifice:
|
|
def test_sacrifice_returns_energy(self):
|
|
card = make_card(cost=3)
|
|
board = [card] + [None] * (BOARD_SIZE - 1)
|
|
state = make_game(p1_board=board, p1_energy=1)
|
|
err = action_sacrifice(state, slot=0)
|
|
assert err is None
|
|
assert state.players["p1"].board[0] is None
|
|
assert state.players["p1"].energy == 2
|
|
|
|
def test_sacrifice_empty_slot(self):
|
|
state = make_game(p1_energy=3)
|
|
err = action_sacrifice(state, slot=0)
|
|
assert err is not None
|
|
assert err == "No card in that slot"
|
|
|
|
def test_sacrifice_invalid_slot(self):
|
|
state = make_game(p1_energy=3)
|
|
err = action_sacrifice(state, slot=99)
|
|
assert err is not None
|
|
assert err == "Invalid slot 99"
|
|
|
|
def test_sacrifice_wrong_phase(self):
|
|
card = make_card(cost=3)
|
|
board = [card] + [None] * (BOARD_SIZE - 1)
|
|
state = make_game(p1_board=board, p1_energy=1)
|
|
state.phase = "combat"
|
|
err = action_sacrifice(state, slot=0)
|
|
assert err is not None
|
|
assert err == "Not in main phase"
|
|
|
|
def test_sacrifice_then_replay(self):
|
|
cheap = make_card(name="Cheap", cost=3)
|
|
expensive = make_card(name="Expensive", cost=5)
|
|
board = [None] + [cheap] + [None] * (BOARD_SIZE - 2)
|
|
state = make_game(p1_board=board, p1_hand=[expensive], p1_energy=4)
|
|
err1 = action_play_card(state, hand_index=0, slot=0)
|
|
assert err1 is not None
|
|
assert err1 == "Not enough energy (have 4, need 5)"
|
|
|
|
action_sacrifice(state, slot=1)
|
|
assert state.players["p1"].energy == 5
|
|
err2 = action_play_card(state, hand_index=0, slot=0)
|
|
assert err2 is None
|
|
|
|
|
|
# ── resolve_combat ────────────────────────────────────────────────────────────
|
|
|
|
class TestResolveCombat:
|
|
def test_uncontested_attack_hits_life(self):
|
|
attacker = make_card(attack=5)
|
|
board = [attacker] + [None] * (BOARD_SIZE - 1)
|
|
state = make_game(p1_board=board)
|
|
events = resolve_combat(state)
|
|
assert state.players["p2"].life == STARTING_LIFE - 5
|
|
assert len(events) == 1
|
|
assert events[0].life_damage == 5
|
|
assert events[0].defender_slot is None
|
|
|
|
def test_contested_attack_reduces_defense(self):
|
|
attacker = make_card(attack=4)
|
|
defender = make_card(defense=10)
|
|
p1_board = [attacker] + [None] * (BOARD_SIZE - 1)
|
|
p2_board = [defender] + [None] * (BOARD_SIZE - 1)
|
|
state = make_game(p1_board=p1_board, p2_board=p2_board)
|
|
events = resolve_combat(state)
|
|
assert defender.defense == 6
|
|
assert state.players["p2"].life == STARTING_LIFE
|
|
assert events[0].life_damage == 0
|
|
assert not events[0].defender_destroyed
|
|
|
|
def test_attack_destroys_defender(self):
|
|
attacker = make_card(attack=10)
|
|
defender = make_card(defense=5)
|
|
p1_board = [attacker] + [None] * (BOARD_SIZE - 1)
|
|
p2_board = [defender] + [None] * (BOARD_SIZE - 1)
|
|
state = make_game(p1_board=p1_board, p2_board=p2_board)
|
|
events = resolve_combat(state)
|
|
assert state.players["p2"].board[0] is None
|
|
assert events[0].defender_destroyed is True
|
|
|
|
def test_no_overflow_damage(self):
|
|
attacker = make_card(attack=100)
|
|
defender = make_card(defense=5)
|
|
p1_board = [attacker] + [None] * (BOARD_SIZE - 1)
|
|
p2_board = [defender] + [None] * (BOARD_SIZE - 1)
|
|
state = make_game(p1_board=p1_board, p2_board=p2_board)
|
|
resolve_combat(state)
|
|
assert state.players["p2"].life == STARTING_LIFE
|
|
|
|
def test_multiple_attackers(self):
|
|
attackers = [make_card(attack=3) for _ in range(3)]
|
|
p1_board = attackers + [None, None]
|
|
state = make_game(p1_board=p1_board)
|
|
resolve_combat(state)
|
|
assert state.players["p2"].life == STARTING_LIFE - 9
|
|
|
|
def test_empty_board_no_events(self):
|
|
state = make_game()
|
|
events = resolve_combat(state)
|
|
assert events == []
|
|
assert state.players["p2"].life == STARTING_LIFE
|
|
|
|
def test_exact_defense_destroys(self):
|
|
attacker = make_card(attack=5)
|
|
defender = make_card(defense=5)
|
|
p1_board = [attacker] + [None] * (BOARD_SIZE - 1)
|
|
p2_board = [defender] + [None] * (BOARD_SIZE - 1)
|
|
state = make_game(p1_board=p1_board, p2_board=p2_board)
|
|
events = resolve_combat(state)
|
|
assert state.players["p2"].board[0] is None
|
|
assert events[0].defender_destroyed is True
|
|
|
|
|
|
# ── check_win_condition ───────────────────────────────────────────────────────
|
|
|
|
class TestWinCondition:
|
|
def test_no_win_initially(self):
|
|
state = make_game()
|
|
assert check_win_condition(state) is None
|
|
|
|
def test_p2_life_zero(self):
|
|
state = make_game(p2_life=0)
|
|
result = check_win_condition(state)
|
|
assert result is not None
|
|
assert result.winner_id == "p1"
|
|
assert result.reason == "Life reduced to zero"
|
|
|
|
def test_p1_life_zero(self):
|
|
state = make_game(p1_life=0)
|
|
result = check_win_condition(state)
|
|
assert result is not None
|
|
assert result.winner_id == "p2"
|
|
assert result.reason == "Life reduced to zero"
|
|
|
|
def test_p1_life_negative(self):
|
|
state = make_game(p1_life=-5)
|
|
result = check_win_condition(state)
|
|
assert result is not None
|
|
assert result.winner_id == "p2"
|
|
assert result.reason == "Life reduced to zero"
|
|
|
|
def test_win_by_no_cards(self):
|
|
card = make_card()
|
|
p1_board = [card] + [None] * (BOARD_SIZE - 1)
|
|
state = make_game(p1_board=p1_board, p2_hand=[], p2_deck=[])
|
|
result = check_win_condition(state)
|
|
assert result is not None
|
|
assert result.winner_id == "p1"
|
|
assert result.reason == "Opponent has no playable cards remaining"
|
|
|
|
def test_no_win_if_p2_has_cards_in_hand(self):
|
|
card_on_board = make_card()
|
|
card_in_hand = make_card()
|
|
p1_board = [card_on_board] + [None] * (BOARD_SIZE - 1)
|
|
state = make_game(p1_board=p1_board, p2_hand=[card_in_hand])
|
|
assert check_win_condition(state) is None
|
|
|
|
def test_no_win_if_attacker_has_no_board(self):
|
|
"""p1 has nothing on board even though p2 is empty."""
|
|
state = make_game(p2_hand=[], p2_deck=[])
|
|
assert check_win_condition(state) is None
|
|
|
|
|
|
# ── action_end_turn ───────────────────────────────────────────────────────────
|
|
|
|
class TestEndTurn:
|
|
def test_switches_active_player(self):
|
|
state = make_game()
|
|
action_end_turn(state)
|
|
assert state.active_player_id == "p2"
|
|
|
|
def test_next_player_draws(self):
|
|
deck = [make_card() for _ in range(10)]
|
|
state = make_game(p2_deck=deck)
|
|
action_end_turn(state)
|
|
assert len(state.players["p2"].hand) == HAND_SIZE
|
|
|
|
def test_next_player_energy_increments(self):
|
|
state = make_game()
|
|
state.players["p2"].energy_cap = 2
|
|
action_end_turn(state)
|
|
assert state.players["p2"].energy_cap == 3
|
|
assert state.players["p2"].energy == 3
|
|
|
|
def test_energy_cap_maxes_out(self):
|
|
state = make_game()
|
|
state.players["p2"].energy_cap = MAX_ENERGY_CAP
|
|
action_end_turn(state)
|
|
assert state.players["p2"].energy_cap == MAX_ENERGY_CAP
|
|
|
|
def test_combat_events_recorded(self):
|
|
attacker = make_card(attack=5)
|
|
p1_board = [attacker] + [None] * (BOARD_SIZE - 1)
|
|
state = make_game(p1_board=p1_board)
|
|
action_end_turn(state)
|
|
assert len(state.last_combat_events) == 1
|
|
|
|
def test_game_ends_on_lethal(self):
|
|
attacker = make_card(attack=STARTING_LIFE)
|
|
p1_board = [attacker] + [None] * (BOARD_SIZE - 1)
|
|
state = make_game(p1_board=p1_board)
|
|
action_end_turn(state)
|
|
assert state.result is not None
|
|
assert state.result.winner_id == "p1"
|
|
assert state.phase == "end"
|
|
|
|
def test_wrong_phase_returns_error(self):
|
|
state = make_game()
|
|
state.phase = "combat"
|
|
err = action_end_turn(state)
|
|
assert err is not None
|
|
|
|
def test_draw_partial_when_deck_small(self):
|
|
deck = [make_card() for _ in range(2)]
|
|
state = make_game(p2_deck=deck)
|
|
action_end_turn(state)
|
|
assert len(state.players["p2"].hand) == 2
|
|
|
|
def test_full_turn_cycle(self):
|
|
card = make_card(cost=1)
|
|
deck = [make_card() for _ in range(10)]
|
|
state = make_game(p1_hand=[card], p2_deck=deck)
|
|
action_play_card(state, hand_index=0, slot=0)
|
|
action_end_turn(state)
|
|
assert state.active_player_id == "p2"
|
|
assert len(state.players["p2"].hand) == HAND_SIZE
|
|
assert state.phase == "main"
|