golfgame/server/test_game.py

574 lines
20 KiB
Python

"""
Test suite for 6-Card Golf game rules.
Verifies our implementation matches canonical 6-Card Golf rules:
- Card values (A=1, 2=-2, 3-10=face, J/Q=10, K=0)
- Column pairing (matching ranks in column = 0 points)
- Draw/discard mechanics
- Cannot re-discard card taken from discard pile
- Round end conditions
- Final turn logic
Run with: pytest test_game.py -v
"""
import pytest
from game import (
Card, Deck, Player, Game, GamePhase, GameOptions,
Suit, Rank, RANK_VALUES
)
# =============================================================================
# Card Value Tests
# =============================================================================
class TestCardValues:
"""Verify card values match standard 6-Card Golf rules."""
def test_ace_worth_1(self):
assert RANK_VALUES[Rank.ACE] == 1
def test_two_worth_negative_2(self):
assert RANK_VALUES[Rank.TWO] == -2
def test_three_through_ten_face_value(self):
assert RANK_VALUES[Rank.THREE] == 3
assert RANK_VALUES[Rank.FOUR] == 4
assert RANK_VALUES[Rank.FIVE] == 5
assert RANK_VALUES[Rank.SIX] == 6
assert RANK_VALUES[Rank.SEVEN] == 7
assert RANK_VALUES[Rank.EIGHT] == 8
assert RANK_VALUES[Rank.NINE] == 9
assert RANK_VALUES[Rank.TEN] == 10
def test_jack_worth_10(self):
assert RANK_VALUES[Rank.JACK] == 10
def test_queen_worth_10(self):
assert RANK_VALUES[Rank.QUEEN] == 10
def test_king_worth_0(self):
assert RANK_VALUES[Rank.KING] == 0
def test_joker_worth_negative_2(self):
assert RANK_VALUES[Rank.JOKER] == -2
def test_card_value_method(self):
"""Card.value() should return correct value."""
card = Card(Suit.HEARTS, Rank.KING)
assert card.value() == 0
card = Card(Suit.SPADES, Rank.TWO)
assert card.value() == -2
# =============================================================================
# Column Pairing Tests
# =============================================================================
class TestColumnPairing:
"""Verify column pair scoring rules."""
def setup_method(self):
"""Create a player with controllable hand."""
self.player = Player(id="test", name="Test")
def set_hand(self, ranks: list[Rank]):
"""Set player's hand to specific ranks (all hearts for simplicity)."""
self.player.cards = [
Card(Suit.HEARTS, rank, face_up=True) for rank in ranks
]
def test_matching_column_scores_zero(self):
"""Two cards of same rank in column = 0 points for that column."""
# Layout: [K, 5, 7]
# [K, 3, 9]
# Column 0 (K-K) = 0, Column 1 (5+3) = 8, Column 2 (7+9) = 16
self.set_hand([Rank.KING, Rank.FIVE, Rank.SEVEN,
Rank.KING, Rank.THREE, Rank.NINE])
score = self.player.calculate_score()
assert score == 24 # 0 + 8 + 16
def test_all_columns_matched(self):
"""All three columns matched = 0 total."""
self.set_hand([Rank.ACE, Rank.FIVE, Rank.KING,
Rank.ACE, Rank.FIVE, Rank.KING])
score = self.player.calculate_score()
assert score == 0
def test_no_columns_matched(self):
"""No matches = sum of all cards."""
# A(1) + 3 + 5 + 7 + 9 + K(0) = 25
self.set_hand([Rank.ACE, Rank.THREE, Rank.FIVE,
Rank.SEVEN, Rank.NINE, Rank.KING])
score = self.player.calculate_score()
assert score == 25
def test_twos_pair_still_zero(self):
"""Paired 2s score 0, not -4 (pair cancels, doesn't double)."""
# [2, 5, 5]
# [2, 5, 5] = all columns matched = 0
self.set_hand([Rank.TWO, Rank.FIVE, Rank.FIVE,
Rank.TWO, Rank.FIVE, Rank.FIVE])
score = self.player.calculate_score()
assert score == 0
def test_negative_cards_unpaired_keep_value(self):
"""Unpaired 2s and Jokers contribute their negative value."""
# [2, K, K]
# [A, K, K] = -2 + 1 + 0 + 0 = -1
self.set_hand([Rank.TWO, Rank.KING, Rank.KING,
Rank.ACE, Rank.KING, Rank.KING])
score = self.player.calculate_score()
assert score == -1
# =============================================================================
# House Rules Scoring Tests
# =============================================================================
class TestHouseRulesScoring:
"""Verify house rule scoring modifiers."""
def setup_method(self):
self.player = Player(id="test", name="Test")
def set_hand(self, ranks: list[Rank]):
self.player.cards = [
Card(Suit.HEARTS, rank, face_up=True) for rank in ranks
]
def test_super_kings_negative_2(self):
"""With super_kings, Kings worth -2."""
options = GameOptions(super_kings=True)
self.set_hand([Rank.KING, Rank.ACE, Rank.ACE,
Rank.THREE, Rank.ACE, Rank.ACE])
score = self.player.calculate_score(options)
# K=-2, 3=3, columns 1&2 matched = 0
assert score == 1
def test_ten_penny(self):
"""With ten_penny, 10s worth 1."""
options = GameOptions(ten_penny=True)
self.set_hand([Rank.TEN, Rank.KING, Rank.KING,
Rank.ACE, Rank.KING, Rank.KING])
score = self.player.calculate_score(options)
# 10=1, A=1, columns 1&2 matched = 0
assert score == 2
def test_lucky_swing_joker(self):
"""With lucky_swing, single Joker worth -5."""
options = GameOptions(use_jokers=True, lucky_swing=True)
self.player.cards = [
Card(Suit.HEARTS, Rank.JOKER, face_up=True),
Card(Suit.HEARTS, Rank.KING, face_up=True),
Card(Suit.HEARTS, Rank.KING, face_up=True),
Card(Suit.HEARTS, Rank.ACE, face_up=True),
Card(Suit.HEARTS, Rank.KING, face_up=True),
Card(Suit.HEARTS, Rank.KING, face_up=True),
]
score = self.player.calculate_score(options)
# Joker=-5, A=1, columns 1&2 matched = 0
assert score == -4
def test_blackjack_21_becomes_0(self):
"""With blackjack option, score of exactly 21 becomes 0."""
# This is applied at round end, not in calculate_score directly
# Testing the raw score first
self.set_hand([Rank.JACK, Rank.ACE, Rank.THREE,
Rank.FOUR, Rank.TWO, Rank.FIVE])
# J=10, A=1, 3=3, 4=4, 2=-2, 5=5 = 21
score = self.player.calculate_score()
assert score == 21
# =============================================================================
# Draw and Discard Mechanics
# =============================================================================
class TestDrawDiscardMechanics:
"""Verify draw/discard rules match standard Golf."""
def setup_method(self):
self.game = Game()
self.game.add_player(Player(id="p1", name="Player 1"))
self.game.add_player(Player(id="p2", name="Player 2"))
# Skip initial flip phase to test draw/discard mechanics directly
self.game.start_game(options=GameOptions(initial_flips=0))
def test_can_draw_from_deck(self):
"""Player can draw from deck."""
card = self.game.draw_card("p1", "deck")
assert card is not None
assert self.game.drawn_card == card
assert self.game.drawn_from_discard is False
def test_can_draw_from_discard(self):
"""Player can draw from discard pile."""
discard_top = self.game.discard_top()
card = self.game.draw_card("p1", "discard")
assert card is not None
assert card == discard_top
assert self.game.drawn_card == card
assert self.game.drawn_from_discard is True
def test_can_discard_deck_draw(self):
"""Card drawn from deck CAN be discarded."""
self.game.draw_card("p1", "deck")
assert self.game.can_discard_drawn() is True
result = self.game.discard_drawn("p1")
assert result is True
def test_cannot_discard_discard_draw(self):
"""Card drawn from discard pile CANNOT be re-discarded."""
self.game.draw_card("p1", "discard")
assert self.game.can_discard_drawn() is False
result = self.game.discard_drawn("p1")
assert result is False
def test_must_swap_discard_draw(self):
"""When drawing from discard, must swap with a hand card."""
self.game.draw_card("p1", "discard")
# Can't discard, must swap
assert self.game.can_discard_drawn() is False
# Swap works
old_card = self.game.swap_card("p1", 0)
assert old_card is not None
assert self.game.drawn_card is None
def test_swap_makes_card_face_up(self):
"""Swapped card is placed face up."""
player = self.game.get_player("p1")
assert player.cards[0].face_up is False # Initially face down
self.game.draw_card("p1", "deck")
self.game.swap_card("p1", 0)
assert player.cards[0].face_up is True
def test_cannot_peek_before_swap(self):
"""Face-down cards stay hidden until swapped/revealed."""
player = self.game.get_player("p1")
# Card is face down
assert player.cards[0].face_up is False
# to_dict doesn't reveal it
card_dict = player.cards[0].to_dict(reveal=False)
assert "rank" not in card_dict
# =============================================================================
# Turn Flow Tests
# =============================================================================
class TestTurnFlow:
"""Verify turn progression rules."""
def setup_method(self):
self.game = Game()
self.game.add_player(Player(id="p1", name="Player 1"))
self.game.add_player(Player(id="p2", name="Player 2"))
self.game.add_player(Player(id="p3", name="Player 3"))
# Skip initial flip phase
self.game.start_game(options=GameOptions(initial_flips=0))
def test_turn_advances_after_discard(self):
"""Turn advances to next player after discarding."""
assert self.game.current_player().id == "p1"
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
assert self.game.current_player().id == "p2"
def test_turn_advances_after_swap(self):
"""Turn advances to next player after swapping."""
assert self.game.current_player().id == "p1"
self.game.draw_card("p1", "deck")
self.game.swap_card("p1", 0)
assert self.game.current_player().id == "p2"
def test_turn_wraps_around(self):
"""Turn wraps from last player to first."""
# Complete turns for p1 and p2
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2")
assert self.game.current_player().id == "p3"
self.game.draw_card("p3", "deck")
self.game.discard_drawn("p3")
assert self.game.current_player().id == "p1" # Wrapped
def test_only_current_player_can_act(self):
"""Only current player can draw."""
assert self.game.current_player().id == "p1"
card = self.game.draw_card("p2", "deck") # Wrong player
assert card is None
# =============================================================================
# Round End Tests
# =============================================================================
class TestRoundEnd:
"""Verify round end conditions and final turn logic."""
def setup_method(self):
self.game = Game()
self.game.add_player(Player(id="p1", name="Player 1"))
self.game.add_player(Player(id="p2", name="Player 2"))
self.game.start_game(options=GameOptions(initial_flips=0))
def reveal_all_cards(self, player_id: str):
"""Helper to flip all cards for a player."""
player = self.game.get_player(player_id)
for card in player.cards:
card.face_up = True
def test_revealing_all_triggers_final_turn(self):
"""When a player reveals all cards, final turn phase begins."""
# Reveal 5 cards for p1
player = self.game.get_player("p1")
for i in range(5):
player.cards[i].face_up = True
assert self.game.phase == GamePhase.PLAYING
# Draw and swap into last face-down position
self.game.draw_card("p1", "deck")
self.game.swap_card("p1", 5) # Last card
assert self.game.phase == GamePhase.FINAL_TURN
assert self.game.finisher_id == "p1"
def test_other_players_get_final_turn(self):
"""After one player finishes, others each get one more turn."""
# P1 reveals all
self.reveal_all_cards("p1")
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
assert self.game.phase == GamePhase.FINAL_TURN
assert self.game.current_player().id == "p2"
# P2 takes final turn
self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2")
# Round should be over
assert self.game.phase == GamePhase.ROUND_OVER
def test_finisher_does_not_get_extra_turn(self):
"""The player who went out doesn't get another turn."""
# P1 reveals all and triggers final turn
self.reveal_all_cards("p1")
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
# P2's turn
assert self.game.current_player().id == "p2"
self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2")
# Should be round over, not p1's turn again
assert self.game.phase == GamePhase.ROUND_OVER
def test_all_cards_revealed_at_round_end(self):
"""At round end, all cards are revealed."""
self.reveal_all_cards("p1")
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2")
assert self.game.phase == GamePhase.ROUND_OVER
# All cards should be face up now
for player in self.game.players:
assert all(card.face_up for card in player.cards)
# =============================================================================
# Multi-Round Tests
# =============================================================================
class TestMultiRound:
"""Verify multi-round game logic."""
def test_next_round_resets_hands(self):
"""Starting next round deals new hands."""
game = Game()
game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2"))
game.start_game(num_rounds=2, options=GameOptions(initial_flips=0))
# Force round end
for player in game.players:
for card in player.cards:
card.face_up = True
game._end_round()
old_cards_p1 = [c.rank for c in game.players[0].cards]
game.start_next_round()
# Cards should be different (statistically)
# and face down again
assert game.phase in (GamePhase.PLAYING, GamePhase.INITIAL_FLIP)
assert not all(game.players[0].cards[i].face_up for i in range(6))
def test_scores_accumulate_across_rounds(self):
"""Total scores persist across rounds."""
game = Game()
game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2"))
game.start_game(num_rounds=2, options=GameOptions(initial_flips=0))
# End round 1
for player in game.players:
for card in player.cards:
card.face_up = True
game._end_round()
round1_total = game.players[0].total_score
game.start_next_round()
# End round 2
for player in game.players:
for card in player.cards:
card.face_up = True
game._end_round()
# Total should have increased (or stayed same if score was 0)
assert game.players[0].total_score >= round1_total or game.players[0].score < 0
# =============================================================================
# Initial Flip Tests
# =============================================================================
class TestInitialFlip:
"""Verify initial flip phase mechanics."""
def test_initial_flip_two_cards(self):
"""With initial_flips=2, players must flip 2 cards."""
game = Game()
game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2"))
game.start_game(options=GameOptions(initial_flips=2))
assert game.phase == GamePhase.INITIAL_FLIP
# Try to flip wrong number
result = game.flip_initial_cards("p1", [0]) # Only 1
assert result is False
# Flip correct number
result = game.flip_initial_cards("p1", [0, 3])
assert result is True
def test_initial_flip_zero_skips_phase(self):
"""With initial_flips=0, skip straight to playing."""
game = Game()
game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2"))
game.start_game(options=GameOptions(initial_flips=0))
assert game.phase == GamePhase.PLAYING
def test_game_starts_after_all_flip(self):
"""Game starts when all players have flipped."""
game = Game()
game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2"))
game.start_game(options=GameOptions(initial_flips=2))
game.flip_initial_cards("p1", [0, 1])
assert game.phase == GamePhase.INITIAL_FLIP # Still waiting
game.flip_initial_cards("p2", [2, 3])
assert game.phase == GamePhase.PLAYING # Now playing
# =============================================================================
# Deck Management Tests
# =============================================================================
class TestDeckManagement:
"""Verify deck initialization and reshuffling."""
def test_standard_deck_52_cards(self):
"""Standard deck has 52 cards."""
deck = Deck(num_decks=1, use_jokers=False)
assert deck.cards_remaining() == 52
def test_joker_deck_54_cards(self):
"""Deck with jokers has 54 cards."""
deck = Deck(num_decks=1, use_jokers=True)
assert deck.cards_remaining() == 54
def test_lucky_swing_single_joker(self):
"""Lucky swing adds only 1 joker total."""
deck = Deck(num_decks=1, use_jokers=True, lucky_swing=True)
assert deck.cards_remaining() == 53
def test_multi_deck(self):
"""Multiple decks multiply cards."""
deck = Deck(num_decks=2, use_jokers=False)
assert deck.cards_remaining() == 104
# =============================================================================
# Edge Cases
# =============================================================================
class TestEdgeCases:
"""Test edge cases and boundary conditions."""
def test_cannot_draw_twice(self):
"""Cannot draw again before playing drawn card."""
game = Game()
game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2"))
game.start_game(options=GameOptions(initial_flips=0))
game.draw_card("p1", "deck")
second_draw = game.draw_card("p1", "deck")
assert second_draw is None
def test_swap_position_bounds(self):
"""Swap position must be 0-5."""
game = Game()
game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2"))
game.start_game(options=GameOptions(initial_flips=0))
game.draw_card("p1", "deck")
result = game.swap_card("p1", -1)
assert result is None
result = game.swap_card("p1", 6)
assert result is None
result = game.swap_card("p1", 3) # Valid
assert result is not None
def test_empty_discard_pile(self):
"""Cannot draw from empty discard pile."""
game = Game()
game.add_player(Player(id="p1", name="Player 1"))
game.add_player(Player(id="p2", name="Player 2"))
game.start_game(options=GameOptions(initial_flips=0))
# Clear discard pile (normally has 1 card)
game.discard_pile = []
card = game.draw_card("p1", "discard")
assert card is None
if __name__ == "__main__":
pytest.main([__file__, "-v"])