Initial commit: Multiplayer Snake game with server discovery
Implemented a complete network multiplayer Snake game with the following features: Core Game: - Client-server architecture using asyncio for networking - Pygame-based rendering at 60 FPS - Server-authoritative game state with 10 TPS - Collision detection (walls, self, other players) - Food spawning and score tracking - Support for multiple players with color-coded snakes Server Discovery: - UDP multicast-based automatic server discovery (239.255.0.1:9999) - Server beacon broadcasts presence every 2 seconds - Client discovery with 3-second timeout - Server selection UI for multiple servers - Auto-connect for single server - Graceful fallback to manual connection Project Structure: - src/shared/ - Protocol, models, constants, discovery utilities - src/server/ - Game server, game logic, server beacon - src/client/ - Game client, renderer, discovery, server selector - tests/ - Unit tests for game logic, models, and discovery Command-line interface with argparse for both server and client. Comprehensive documentation in README.md and CLAUDE.md. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
159
tests/test_discovery.py
Normal file
159
tests/test_discovery.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Tests for server discovery functionality."""
|
||||
|
||||
import pytest
|
||||
from src.shared.discovery import (
|
||||
ServerInfo,
|
||||
DiscoveryMessage,
|
||||
create_multicast_socket,
|
||||
get_local_ip,
|
||||
)
|
||||
|
||||
|
||||
class TestServerInfo:
|
||||
"""Test suite for ServerInfo class."""
|
||||
|
||||
def test_server_info_creation(self) -> None:
|
||||
"""Test creating ServerInfo."""
|
||||
info = ServerInfo(
|
||||
host="192.168.1.100",
|
||||
port=8888,
|
||||
server_name="Test Server",
|
||||
players_count=3,
|
||||
)
|
||||
|
||||
assert info.host == "192.168.1.100"
|
||||
assert info.port == 8888
|
||||
assert info.server_name == "Test Server"
|
||||
assert info.players_count == 3
|
||||
|
||||
def test_server_info_serialization(self) -> None:
|
||||
"""Test ServerInfo to_dict and from_dict."""
|
||||
info = ServerInfo(
|
||||
host="10.0.0.1",
|
||||
port=9999,
|
||||
server_name="Game Room",
|
||||
players_count=2,
|
||||
)
|
||||
|
||||
# Serialize
|
||||
data = info.to_dict()
|
||||
assert data["host"] == "10.0.0.1"
|
||||
assert data["port"] == 9999
|
||||
assert data["server_name"] == "Game Room"
|
||||
assert data["players_count"] == 2
|
||||
|
||||
# Deserialize
|
||||
info2 = ServerInfo.from_dict(data)
|
||||
assert info2.host == info.host
|
||||
assert info2.port == info.port
|
||||
assert info2.server_name == info.server_name
|
||||
assert info2.players_count == info.players_count
|
||||
|
||||
def test_server_info_host_override(self) -> None:
|
||||
"""Test ServerInfo from_dict with host override."""
|
||||
data = {
|
||||
"host": "1.2.3.4",
|
||||
"port": 8888,
|
||||
"server_name": "Test",
|
||||
"players_count": 1,
|
||||
}
|
||||
|
||||
# Override host
|
||||
info = ServerInfo.from_dict(data, host="5.6.7.8")
|
||||
assert info.host == "5.6.7.8"
|
||||
assert info.port == 8888
|
||||
|
||||
|
||||
class TestDiscoveryMessage:
|
||||
"""Test suite for DiscoveryMessage class."""
|
||||
|
||||
def test_create_discover_message(self) -> None:
|
||||
"""Test creating DISCOVER message."""
|
||||
msg = DiscoveryMessage.create_discover()
|
||||
assert isinstance(msg, bytes)
|
||||
|
||||
# Parse it back
|
||||
msg_type, msg_data = DiscoveryMessage.parse(msg)
|
||||
assert msg_type == DiscoveryMessage.DISCOVER
|
||||
assert msg_data is None or msg_data == {}
|
||||
|
||||
def test_create_announce_message(self) -> None:
|
||||
"""Test creating SERVER_ANNOUNCE message."""
|
||||
server_info = ServerInfo(
|
||||
host="192.168.1.1",
|
||||
port=8888,
|
||||
server_name="Test Server",
|
||||
players_count=5,
|
||||
)
|
||||
|
||||
msg = DiscoveryMessage.create_announce(server_info)
|
||||
assert isinstance(msg, bytes)
|
||||
|
||||
# Parse it back
|
||||
msg_type, msg_data = DiscoveryMessage.parse(msg)
|
||||
assert msg_type == DiscoveryMessage.SERVER_ANNOUNCE
|
||||
assert msg_data is not None
|
||||
assert msg_data["host"] == "192.168.1.1"
|
||||
assert msg_data["port"] == 8888
|
||||
assert msg_data["server_name"] == "Test Server"
|
||||
assert msg_data["players_count"] == 5
|
||||
|
||||
def test_parse_invalid_message(self) -> None:
|
||||
"""Test parsing invalid message."""
|
||||
msg_type, msg_data = DiscoveryMessage.parse(b"invalid json")
|
||||
assert msg_type is None
|
||||
assert msg_data is None
|
||||
|
||||
def test_message_round_trip(self) -> None:
|
||||
"""Test encoding and decoding messages."""
|
||||
# Test DISCOVER
|
||||
discover = DiscoveryMessage.create_discover()
|
||||
msg_type, _ = DiscoveryMessage.parse(discover)
|
||||
assert msg_type == DiscoveryMessage.DISCOVER
|
||||
|
||||
# Test ANNOUNCE
|
||||
info = ServerInfo(
|
||||
host="10.0.0.1",
|
||||
port=7777,
|
||||
server_name="Round Trip",
|
||||
players_count=0,
|
||||
)
|
||||
announce = DiscoveryMessage.create_announce(info)
|
||||
msg_type, msg_data = DiscoveryMessage.parse(announce)
|
||||
assert msg_type == DiscoveryMessage.SERVER_ANNOUNCE
|
||||
assert msg_data["server_name"] == "Round Trip"
|
||||
|
||||
|
||||
class TestDiscoveryUtilities:
|
||||
"""Test suite for discovery utility functions."""
|
||||
|
||||
def test_create_multicast_socket(self) -> None:
|
||||
"""Test creating multicast socket."""
|
||||
# Test without binding
|
||||
sock = create_multicast_socket(bind=False)
|
||||
assert sock is not None
|
||||
sock.close()
|
||||
|
||||
# Test with binding (may fail in some environments)
|
||||
try:
|
||||
sock = create_multicast_socket(bind=True)
|
||||
assert sock is not None
|
||||
sock.close()
|
||||
except Exception:
|
||||
# Binding may fail in restricted environments
|
||||
pytest.skip("Cannot bind to multicast group in this environment")
|
||||
|
||||
def test_get_local_ip(self) -> None:
|
||||
"""Test getting local IP address."""
|
||||
ip = get_local_ip()
|
||||
assert isinstance(ip, str)
|
||||
assert len(ip) > 0
|
||||
|
||||
# Should be a valid IP format
|
||||
parts = ip.split(".")
|
||||
assert len(parts) == 4
|
||||
|
||||
# Each part should be a number 0-255
|
||||
for part in parts:
|
||||
num = int(part)
|
||||
assert 0 <= num <= 255
|
||||
148
tests/test_game_logic.py
Normal file
148
tests/test_game_logic.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Tests for game logic."""
|
||||
|
||||
import pytest
|
||||
from src.server.game_logic import GameLogic
|
||||
from src.shared.models import Position, Snake
|
||||
from src.shared.constants import GRID_WIDTH, GRID_HEIGHT, RIGHT, LEFT, UP, DOWN
|
||||
|
||||
|
||||
class TestGameLogic:
|
||||
"""Test suite for GameLogic class."""
|
||||
|
||||
def test_create_snake(self) -> None:
|
||||
"""Test snake creation."""
|
||||
logic = GameLogic()
|
||||
snake = logic.create_snake("player1")
|
||||
|
||||
assert snake.player_id == "player1"
|
||||
assert len(snake.body) == 3
|
||||
assert snake.alive is True
|
||||
assert snake.score == 0
|
||||
assert snake.direction == RIGHT
|
||||
|
||||
def test_spawn_food(self) -> None:
|
||||
"""Test food spawning."""
|
||||
logic = GameLogic()
|
||||
food = logic.spawn_food()
|
||||
|
||||
assert 0 <= food.position.x < GRID_WIDTH
|
||||
assert 0 <= food.position.y < GRID_HEIGHT
|
||||
|
||||
def test_update_snake_direction(self) -> None:
|
||||
"""Test updating snake direction."""
|
||||
logic = GameLogic()
|
||||
snake = logic.create_snake("player1")
|
||||
logic.state.snakes.append(snake)
|
||||
|
||||
# Valid direction change
|
||||
logic.update_snake_direction("player1", UP)
|
||||
assert snake.direction == UP
|
||||
|
||||
# Invalid 180-degree turn (should be ignored)
|
||||
logic.update_snake_direction("player1", DOWN)
|
||||
assert snake.direction == UP # Should remain UP
|
||||
|
||||
def test_move_snakes(self) -> None:
|
||||
"""Test snake movement."""
|
||||
logic = GameLogic()
|
||||
snake = Snake(player_id="player1", body=[
|
||||
Position(5, 5),
|
||||
Position(4, 5),
|
||||
Position(3, 5),
|
||||
], direction=RIGHT)
|
||||
logic.state.snakes.append(snake)
|
||||
|
||||
initial_length = len(snake.body)
|
||||
logic.move_snakes()
|
||||
|
||||
# Snake should have moved one cell to the right
|
||||
assert snake.get_head().x == 6
|
||||
assert snake.get_head().y == 5
|
||||
assert len(snake.body) == initial_length
|
||||
|
||||
def test_collision_with_wall(self) -> None:
|
||||
"""Test collision detection with walls."""
|
||||
logic = GameLogic()
|
||||
|
||||
# Snake at left wall
|
||||
snake = Snake(player_id="player1", body=[
|
||||
Position(0, 5),
|
||||
Position(1, 5),
|
||||
Position(2, 5),
|
||||
], direction=LEFT)
|
||||
logic.state.snakes.append(snake)
|
||||
|
||||
logic.move_snakes()
|
||||
logic.check_collisions()
|
||||
|
||||
assert snake.alive is False
|
||||
|
||||
def test_collision_with_self(self) -> None:
|
||||
"""Test collision detection with self."""
|
||||
logic = GameLogic()
|
||||
|
||||
# Create a snake that will hit itself
|
||||
snake = Snake(player_id="player1", body=[
|
||||
Position(5, 5),
|
||||
Position(5, 6),
|
||||
Position(6, 6),
|
||||
Position(6, 5),
|
||||
], direction=DOWN)
|
||||
logic.state.snakes.append(snake)
|
||||
|
||||
logic.move_snakes()
|
||||
logic.check_collisions()
|
||||
|
||||
assert snake.alive is False
|
||||
|
||||
def test_food_eating(self) -> None:
|
||||
"""Test snake eating food and growing."""
|
||||
logic = GameLogic()
|
||||
|
||||
snake = Snake(player_id="player1", body=[
|
||||
Position(5, 5),
|
||||
Position(4, 5),
|
||||
Position(3, 5),
|
||||
], direction=RIGHT)
|
||||
logic.state.snakes.append(snake)
|
||||
|
||||
# Place food in front of snake
|
||||
from src.shared.models import Food
|
||||
food = Food(position=Position(6, 5))
|
||||
logic.state.food.append(food)
|
||||
|
||||
initial_length = len(snake.body)
|
||||
initial_score = snake.score
|
||||
|
||||
logic.move_snakes()
|
||||
|
||||
# Snake should have grown and scored
|
||||
assert len(snake.body) == initial_length + 1
|
||||
assert snake.score == initial_score + 10
|
||||
assert food not in logic.state.food
|
||||
|
||||
def test_is_game_over(self) -> None:
|
||||
"""Test game over detection."""
|
||||
logic = GameLogic()
|
||||
|
||||
# No game over with multiple alive snakes
|
||||
snake1 = Snake(player_id="player1", alive=True)
|
||||
snake2 = Snake(player_id="player2", alive=True)
|
||||
logic.state.snakes = [snake1, snake2]
|
||||
|
||||
assert logic.is_game_over() is False
|
||||
|
||||
# Game over when only one snake alive
|
||||
snake2.alive = False
|
||||
assert logic.is_game_over() is True
|
||||
|
||||
def test_get_winner(self) -> None:
|
||||
"""Test winner determination."""
|
||||
logic = GameLogic()
|
||||
|
||||
snake1 = Snake(player_id="player1", alive=True)
|
||||
snake2 = Snake(player_id="player2", alive=False)
|
||||
logic.state.snakes = [snake1, snake2]
|
||||
|
||||
winner = logic.get_winner()
|
||||
assert winner == "player1"
|
||||
135
tests/test_models.py
Normal file
135
tests/test_models.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Tests for data models."""
|
||||
|
||||
import pytest
|
||||
from src.shared.models import Position, Snake, Food, GameState
|
||||
|
||||
|
||||
class TestPosition:
|
||||
"""Test suite for Position class."""
|
||||
|
||||
def test_position_creation(self) -> None:
|
||||
"""Test creating a position."""
|
||||
pos = Position(5, 10)
|
||||
assert pos.x == 5
|
||||
assert pos.y == 10
|
||||
|
||||
def test_position_addition(self) -> None:
|
||||
"""Test adding a direction to a position."""
|
||||
pos = Position(5, 10)
|
||||
new_pos = pos + (1, -1)
|
||||
assert new_pos.x == 6
|
||||
assert new_pos.y == 9
|
||||
|
||||
def test_position_to_tuple(self) -> None:
|
||||
"""Test converting position to tuple."""
|
||||
pos = Position(3, 7)
|
||||
assert pos.to_tuple() == (3, 7)
|
||||
|
||||
def test_position_from_tuple(self) -> None:
|
||||
"""Test creating position from tuple."""
|
||||
pos = Position.from_tuple((3, 7))
|
||||
assert pos.x == 3
|
||||
assert pos.y == 7
|
||||
|
||||
|
||||
class TestSnake:
|
||||
"""Test suite for Snake class."""
|
||||
|
||||
def test_snake_creation(self) -> None:
|
||||
"""Test creating a snake."""
|
||||
snake = Snake(player_id="test123")
|
||||
assert snake.player_id == "test123"
|
||||
assert snake.alive is True
|
||||
assert snake.score == 0
|
||||
assert snake.direction == (1, 0)
|
||||
|
||||
def test_get_head(self) -> None:
|
||||
"""Test getting snake head position."""
|
||||
snake = Snake(
|
||||
player_id="test",
|
||||
body=[Position(5, 5), Position(4, 5), Position(3, 5)]
|
||||
)
|
||||
head = snake.get_head()
|
||||
assert head.x == 5
|
||||
assert head.y == 5
|
||||
|
||||
def test_snake_serialization(self) -> None:
|
||||
"""Test snake to_dict and from_dict."""
|
||||
snake = Snake(
|
||||
player_id="test123",
|
||||
body=[Position(5, 5), Position(4, 5)],
|
||||
direction=(0, 1),
|
||||
alive=False,
|
||||
score=100
|
||||
)
|
||||
|
||||
# Serialize
|
||||
data = snake.to_dict()
|
||||
assert data["player_id"] == "test123"
|
||||
assert data["body"] == [(5, 5), (4, 5)]
|
||||
assert data["direction"] == (0, 1)
|
||||
assert data["alive"] is False
|
||||
assert data["score"] == 100
|
||||
|
||||
# Deserialize
|
||||
snake2 = Snake.from_dict(data)
|
||||
assert snake2.player_id == snake.player_id
|
||||
assert len(snake2.body) == len(snake.body)
|
||||
assert snake2.body[0].x == snake.body[0].x
|
||||
assert snake2.direction == snake.direction
|
||||
assert snake2.alive == snake.alive
|
||||
assert snake2.score == snake.score
|
||||
|
||||
|
||||
class TestFood:
|
||||
"""Test suite for Food class."""
|
||||
|
||||
def test_food_creation(self) -> None:
|
||||
"""Test creating food."""
|
||||
food = Food(position=Position(10, 15))
|
||||
assert food.position.x == 10
|
||||
assert food.position.y == 15
|
||||
|
||||
def test_food_serialization(self) -> None:
|
||||
"""Test food to_dict and from_dict."""
|
||||
food = Food(position=Position(10, 15))
|
||||
|
||||
# Serialize
|
||||
data = food.to_dict()
|
||||
assert data["position"] == (10, 15)
|
||||
|
||||
# Deserialize
|
||||
food2 = Food.from_dict(data)
|
||||
assert food2.position.x == food.position.x
|
||||
assert food2.position.y == food.position.y
|
||||
|
||||
|
||||
class TestGameState:
|
||||
"""Test suite for GameState class."""
|
||||
|
||||
def test_game_state_creation(self) -> None:
|
||||
"""Test creating game state."""
|
||||
state = GameState()
|
||||
assert len(state.snakes) == 0
|
||||
assert len(state.food) == 0
|
||||
assert state.game_running is False
|
||||
|
||||
def test_game_state_serialization(self) -> None:
|
||||
"""Test game state to_dict and from_dict."""
|
||||
state = GameState()
|
||||
state.snakes.append(Snake(player_id="p1", body=[Position(5, 5)]))
|
||||
state.food.append(Food(position=Position(10, 10)))
|
||||
state.game_running = True
|
||||
|
||||
# Serialize
|
||||
data = state.to_dict()
|
||||
assert len(data["snakes"]) == 1
|
||||
assert len(data["food"]) == 1
|
||||
assert data["game_running"] is True
|
||||
|
||||
# Deserialize
|
||||
state2 = GameState.from_dict(data)
|
||||
assert len(state2.snakes) == 1
|
||||
assert len(state2.food) == 1
|
||||
assert state2.game_running is True
|
||||
assert state2.snakes[0].player_id == "p1"
|
||||
Reference in New Issue
Block a user