- 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>
7.2 KiB
V3-01: Dealer/Starting Player Rotation
Overview
In physical card games, the deal rotates clockwise after each hand. The player who deals also typically plays last (or the player to their left plays first). Currently, our game always starts with the host/first player each round.
Dependencies: None (server-side foundation) Dependents: V3_02 (Dealing Animation needs to know who is dealing)
Goals
- Track the current dealer position across rounds
- Rotate dealer clockwise after each round
- First player to act is to the left of the dealer (next in order)
- Communicate dealer position to clients
- Visual indicator of current dealer (client-side, prep for V3_02)
Current State
From server/game.py, round start logic:
def start_next_round(self):
"""Start the next round."""
self.current_round += 1
# ... deal cards ...
# Current player is always index 0 (host/first joiner)
self.current_player_idx = 0
The player_order list is set once at game start and never changes. The first player is always player_order[0].
Design
Server Changes
New State Fields
# In Game class __init__
self.dealer_idx = 0 # Index into player_order of current dealer
Round Start Logic
def start_next_round(self):
"""Start the next round."""
self.current_round += 1
# Rotate dealer clockwise (next player in order)
if self.current_round > 1:
self.dealer_idx = (self.dealer_idx + 1) % len(self.player_order)
# First player is to the LEFT of dealer (next after dealer)
self.current_player_idx = (self.dealer_idx + 1) % len(self.player_order)
# ... rest of dealing logic ...
Game State Response
Add dealer info to the game state sent to clients:
def get_state(self, for_player_id: str) -> dict:
return {
# ... existing fields ...
"dealer_id": self.player_order[self.dealer_idx] if self.player_order else None,
"dealer_idx": self.dealer_idx,
# current_player_id already exists
}
Client Changes
State Handling
In app.js, the gameState will now include:
dealer_id- The player ID of the current dealerdealer_idx- Index for ordering
Visual Indicator
Add a dealer chip/badge to the current dealer's area:
// In renderGame() or opponent rendering
const isDealer = player.id === this.gameState.dealer_id;
if (isDealer) {
div.classList.add('is-dealer');
// Add dealer chip element
}
CSS
/* Dealer indicator */
.is-dealer::before {
content: "D";
position: absolute;
top: -8px;
left: -8px;
width: 20px;
height: 20px;
background: #f4a460;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
color: #1a1a2e;
border: 2px solid #fff;
z-index: 10;
}
/* Or use a chip emoji/icon */
.dealer-chip {
position: absolute;
top: -10px;
right: -10px;
font-size: 1.2em;
}
Edge Cases
Player Leaves Mid-Game
If the current dealer leaves:
- Dealer position should stay at the same index
- If that index is now out of bounds, wrap to 0
- The show must go on
def remove_player(self, player_id: str):
# ... existing removal logic ...
# Adjust dealer_idx if needed
if self.dealer_idx >= len(self.player_order):
self.dealer_idx = 0
2-Player Game
With 2 players, dealer alternates each round:
- Round 1: Player A deals, Player B plays first
- Round 2: Player B deals, Player A plays first
- This works naturally with the modulo logic
Game Start (Round 1)
For round 1:
- Dealer is the host (player_order[0])
- First player is player_order[1] (or player_order[0] in solo/test)
Option: Could randomize initial dealer, but host-as-first-dealer is traditional.
Test Cases
# server/tests/test_dealer_rotation.py
def test_dealer_starts_as_host():
"""First round dealer is the host (first player)."""
game = create_game_with_players(["Alice", "Bob", "Carol"])
game.start_game()
assert game.dealer_idx == 0
assert game.get_dealer_id() == "Alice"
# First player is to dealer's left
assert game.current_player_idx == 1
assert game.get_current_player_id() == "Bob"
def test_dealer_rotates_each_round():
"""Dealer advances clockwise after each round."""
game = create_game_with_players(["Alice", "Bob", "Carol"])
game.start_game()
# Round 1: Alice deals, Bob plays first
assert game.dealer_idx == 0
complete_round(game)
game.start_next_round()
# Round 2: Bob deals, Carol plays first
assert game.dealer_idx == 1
assert game.current_player_idx == 2
complete_round(game)
game.start_next_round()
# Round 3: Carol deals, Alice plays first
assert game.dealer_idx == 2
assert game.current_player_idx == 0
def test_dealer_wraps_around():
"""Dealer wraps to first player after last player deals."""
game = create_game_with_players(["Alice", "Bob"])
game.start_game()
# Round 1: Alice deals
assert game.dealer_idx == 0
complete_round(game)
game.start_next_round()
# Round 2: Bob deals
assert game.dealer_idx == 1
complete_round(game)
game.start_next_round()
# Round 3: Back to Alice
assert game.dealer_idx == 0
def test_dealer_adjustment_on_player_leave():
"""Dealer index adjusts when players leave."""
game = create_game_with_players(["Alice", "Bob", "Carol"])
game.start_game()
complete_round(game)
game.start_next_round()
# Bob is now dealer (idx 1)
game.remove_player("Carol") # Remove last player
# Dealer idx should still be valid
assert game.dealer_idx == 1
assert game.dealer_idx < len(game.player_order)
def test_state_includes_dealer_info():
"""Game state includes dealer information."""
game = create_game_with_players(["Alice", "Bob"])
game.start_game()
state = game.get_state("Alice")
assert "dealer_id" in state
assert state["dealer_id"] == "Alice"
Implementation Order
- Add
dealer_idxfield to Game class - Modify
start_game()to set initial dealer - Modify
start_next_round()to rotate dealer - Modify
get_state()to include dealer info - Handle edge case: player leaves
- Add tests for dealer rotation
- Client: Add dealer visual indicator
- Client: Style the dealer chip/badge
Acceptance Criteria
- Round 1 dealer is the host (first player in order)
- Dealer rotates clockwise after each round
- First player to act is always left of dealer
- Dealer info included in game state sent to clients
- Dealer position survives player departure
- Visual indicator shows current dealer
- All existing tests still pass
Notes for Agent
- The
player_orderlist is established at game start and defines clockwise order - Keep backward compatibility - games in progress shouldn't break
- The dealer indicator is prep work for V3_02 (dealing animation)
- Consider: Should dealer deal to themselves last? (Traditional, but not gameplay-affecting)
- The visual dealer chip will become important when dealing animation shows cards coming FROM the dealer