v3.0.0: V3 features, server refactoring, and documentation overhaul

- Extract WebSocket handlers from main.py into handlers.py
- Add V3 feature docs (dealer rotation, dealing animation, round end reveal,
  column pair celebration, final turn urgency, opponent thinking, score tallying,
  card hover/selection, knock early drama, column pair indicator, swap animation
  improvements, draw source distinction, card value tooltips, active rules context,
  discard pile history, realistic card sounds)
- Add V3 refactoring docs (ai.py, main.py/game.py, misc improvements)
- Add installation guide with Docker, systemd, and nginx setup
- Add helper scripts (install.sh, dev-server.sh, docker-build.sh)
- Add animation flow diagrams documentation
- Add test files for handlers, rooms, and V3 features
- Add e2e test specs for V3 features
- Update README with complete project structure and current tech stack
- Update CLAUDE.md with full architecture tree and server layer descriptions
- Update .env.example to reflect PostgreSQL (remove SQLite references)
- Update .gitignore to exclude virtualenv files, .claude/, and .db files
- Remove tracked virtualenv files (bin/, lib64, pyvenv.cfg)
- Remove obsolete game_log.py (SQLite) and games.db

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-02-14 10:03:45 -05:00
parent 13ab5b9017
commit 9fc6b83bba
60 changed files with 11791 additions and 1639 deletions

View File

@@ -196,10 +196,12 @@ class TestDrawDiscardMechanics:
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))
# Get the actual current player (after dealer rotation, it's p2)
self.current_player_id = self.game.current_player().id
def test_can_draw_from_deck(self):
"""Player can draw from deck."""
card = self.game.draw_card("p1", "deck")
card = self.game.draw_card(self.current_player_id, "deck")
assert card is not None
assert self.game.drawn_card == card
assert self.game.drawn_from_discard is False
@@ -207,7 +209,7 @@ class TestDrawDiscardMechanics:
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")
card = self.game.draw_card(self.current_player_id, "discard")
assert card is not None
assert card == discard_top
assert self.game.drawn_card == card
@@ -215,40 +217,40 @@ class TestDrawDiscardMechanics:
def test_can_discard_deck_draw(self):
"""Card drawn from deck CAN be discarded."""
self.game.draw_card("p1", "deck")
self.game.draw_card(self.current_player_id, "deck")
assert self.game.can_discard_drawn() is True
result = self.game.discard_drawn("p1")
result = self.game.discard_drawn(self.current_player_id)
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")
self.game.draw_card(self.current_player_id, "discard")
assert self.game.can_discard_drawn() is False
result = self.game.discard_drawn("p1")
result = self.game.discard_drawn(self.current_player_id)
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")
self.game.draw_card(self.current_player_id, "discard")
# Can't discard, must swap
assert self.game.can_discard_drawn() is False
# Swap works
old_card = self.game.swap_card("p1", 0)
old_card = self.game.swap_card(self.current_player_id, 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")
player = self.game.get_player(self.current_player_id)
assert player.cards[0].face_up is False # Initially face down
self.game.draw_card("p1", "deck")
self.game.swap_card("p1", 0)
self.game.draw_card(self.current_player_id, "deck")
self.game.swap_card(self.current_player_id, 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")
player = self.game.get_player(self.current_player_id)
# Card is face down
assert player.cards[0].face_up is False
# to_client_dict hides face-down card details from clients
@@ -274,38 +276,42 @@ class TestTurnFlow:
self.game.add_player(Player(id="p3", name="Player 3"))
# Skip initial flip phase
self.game.start_game(options=GameOptions(initial_flips=0))
# With dealer rotation (V3_01): dealer=p1(idx 0), first player=p2(idx 1)
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")
# First player after dealer is p2
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"
def test_turn_advances_after_swap(self):
"""Turn advances to next player after swapping."""
assert self.game.current_player().id == "p2"
self.game.draw_card("p2", "deck")
self.game.swap_card("p2", 0)
assert self.game.current_player().id == "p3"
def test_turn_wraps_around(self):
"""Turn wraps from last player to first."""
# Order is: p2 -> p3 -> p1 -> p2 (wraps)
# Complete turns for p2 and p3
self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2")
self.game.draw_card("p3", "deck")
self.game.discard_drawn("p3")
assert self.game.current_player().id == "p1" # Wrapped
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" # 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
# First player is p2 (after dealer p1)
assert self.game.current_player().id == "p2"
card = self.game.draw_card("p1", "deck") # Wrong player (dealer can't go first)
assert card is None
@@ -321,6 +327,7 @@ class TestRoundEnd:
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))
# With dealer rotation: dealer=p1(idx 0), first player=p2(idx 1)
def reveal_all_cards(self, player_id: str):
"""Helper to flip all cards for a player."""
@@ -330,61 +337,64 @@ class TestRoundEnd:
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")
# First player is p2 (after dealer p1)
# Reveal 5 cards for p2
player = self.game.get_player("p2")
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
self.game.draw_card("p2", "deck")
self.game.swap_card("p2", 5) # Last card
assert self.game.phase == GamePhase.FINAL_TURN
assert self.game.finisher_id == "p1"
assert self.game.finisher_id == "p2"
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
# First player is p2, they reveal all
self.reveal_all_cards("p2")
self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2")
assert self.game.phase == GamePhase.FINAL_TURN
assert self.game.current_player().id == "p1"
# P1 takes final turn
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
# 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"
# p2 goes first, reveals all and triggers final turn
self.reveal_all_cards("p2")
self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2")
# Should be round over, not p1's turn again
# P1's turn (the other player)
assert self.game.current_player().id == "p1"
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
# Should be round over, not p2'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")
# p2 goes first, reveals all
self.reveal_all_cards("p2")
self.game.draw_card("p2", "deck")
self.game.discard_drawn("p2")
# p1 takes final turn
self.game.draw_card("p1", "deck")
self.game.discard_drawn("p1")
assert self.game.phase == GamePhase.ROUND_OVER
# All cards should be face up now
@@ -536,9 +546,11 @@ class TestEdgeCases:
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))
# First player is p2 after dealer rotation
current_id = game.current_player().id
game.draw_card("p1", "deck")
second_draw = game.draw_card("p1", "deck")
game.draw_card(current_id, "deck")
second_draw = game.draw_card(current_id, "deck")
assert second_draw is None
def test_swap_position_bounds(self):
@@ -547,16 +559,17 @@ class TestEdgeCases:
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))
current_id = game.current_player().id
game.draw_card("p1", "deck")
game.draw_card(current_id, "deck")
result = game.swap_card("p1", -1)
result = game.swap_card(current_id, -1)
assert result is None
result = game.swap_card("p1", 6)
result = game.swap_card(current_id, 6)
assert result is None
result = game.swap_card("p1", 3) # Valid
result = game.swap_card(current_id, 3) # Valid
assert result is not None
def test_empty_discard_pile(self):
@@ -565,11 +578,12 @@ class TestEdgeCases:
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))
current_id = game.current_player().id
# Clear discard pile (normally has 1 card)
game.discard_pile = []
card = game.draw_card("p1", "discard")
card = game.draw_card(current_id, "discard")
assert card is None