Input buffer system: - Added 3-slot direction input buffer to handle rapid key presses - Buffer ignores duplicate inputs (same key pressed multiple times) - Opposite direction replaces last buffered input (e.g., LEFT→RIGHT replaces LEFT) - Buffer overflow replaces last slot when full - Multi-segment snakes skip invalid 180° turns when consuming buffer - Head-only snakes (length=1) can turn 180° for flexibility Gameplay improvements: - Desktop client auto-starts game on connect (no SPACE needed) - Field populates with 3 apples when no players connected - HTTP server now binds to 0.0.0.0 for network access (matches game server) Testing: - Added 7 new tests for input buffer functionality - Added test for zero-player apple spawning - All 19 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
371 lines
12 KiB
Python
371 lines
12 KiB
Python
"""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 via input buffer."""
|
|
logic = GameLogic()
|
|
snake = logic.create_snake("player1")
|
|
logic.state.snakes.append(snake)
|
|
|
|
# Direction changes go into buffer first
|
|
logic.update_snake_direction("player1", UP)
|
|
assert snake.input_buffer == [UP]
|
|
assert snake.direction == RIGHT # Original direction unchanged
|
|
|
|
# Moving consumes from buffer
|
|
logic.move_snakes()
|
|
assert snake.direction == UP # Now changed after movement
|
|
assert len(snake.input_buffer) == 0
|
|
|
|
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 - snake gets stuck."""
|
|
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)
|
|
|
|
initial_length = len(snake.body)
|
|
head_pos = snake.get_head()
|
|
|
|
logic.move_snakes()
|
|
|
|
# Snake should be stuck, not dead
|
|
assert snake.stuck is True
|
|
assert snake.alive is True
|
|
# Head position should not change
|
|
assert snake.get_head().x == head_pos.x
|
|
assert snake.get_head().y == head_pos.y
|
|
# Tail should shrink by 1
|
|
assert len(snake.body) == initial_length - 1
|
|
|
|
def test_collision_with_self(self) -> None:
|
|
"""Test collision detection with self - snake gets stuck then auto-unsticks."""
|
|
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)
|
|
|
|
# First move: should get stuck
|
|
logic.move_snakes()
|
|
assert snake.stuck is True
|
|
assert snake.alive is True
|
|
assert len(snake.body) == 3 # Shrunk by 1
|
|
|
|
# Continue moving: tail shrinks, eventually unsticks
|
|
logic.move_snakes()
|
|
assert len(snake.body) == 2 # Shrunk again
|
|
|
|
logic.move_snakes()
|
|
# After enough shrinking, blocking segment is gone, snake unsticks
|
|
assert len(snake.body) >= 1
|
|
|
|
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_stuck_minimum_length(self) -> None:
|
|
"""Test that snake cannot shrink below length 1."""
|
|
logic = GameLogic()
|
|
|
|
# Create length-1 snake stuck against wall
|
|
snake = Snake(player_id="player1", body=[Position(0, 5)], direction=LEFT)
|
|
logic.state.snakes.append(snake)
|
|
|
|
# Move multiple times - should stay stuck, not shrink below 1
|
|
for _ in range(5):
|
|
logic.move_snakes()
|
|
assert len(snake.body) == 1
|
|
assert snake.stuck is True
|
|
assert snake.alive is True
|
|
|
|
def test_other_snake_blocks(self) -> None:
|
|
"""Test snake getting stuck on another snake."""
|
|
logic = GameLogic()
|
|
|
|
# Snake A trying to move into Snake B
|
|
snake_a = Snake(player_id="player_a", body=[
|
|
Position(5, 5),
|
|
Position(4, 5),
|
|
], direction=RIGHT)
|
|
snake_b = Snake(player_id="player_b", body=[
|
|
Position(6, 5), # Blocking Snake A
|
|
Position(7, 5),
|
|
], direction=RIGHT)
|
|
logic.state.snakes.extend([snake_a, snake_b])
|
|
|
|
logic.move_snakes()
|
|
|
|
# Snake A should be stuck
|
|
assert snake_a.stuck is True
|
|
assert len(snake_a.body) == 1 # Shrunk by 1
|
|
|
|
def test_other_snake_moves_away(self) -> None:
|
|
"""Test snake auto-unsticks when other snake moves away."""
|
|
logic = GameLogic()
|
|
|
|
# Snake A stuck behind Snake B
|
|
snake_a = Snake(player_id="player_a", body=[
|
|
Position(5, 5),
|
|
Position(4, 5),
|
|
Position(3, 5),
|
|
], direction=RIGHT)
|
|
snake_b = Snake(player_id="player_b", body=[
|
|
Position(6, 5), # Blocking at (6,5)
|
|
Position(6, 6), # Tail at (6,6)
|
|
], direction=UP) # Will move up
|
|
logic.state.snakes.extend([snake_a, snake_b])
|
|
|
|
# Tick 1: Snake A gets stuck, Snake B moves to (6,4)-(6,5)
|
|
logic.move_snakes()
|
|
assert snake_a.stuck is True
|
|
assert len(snake_a.body) == 2 # Shrunk by 1
|
|
|
|
# Tick 2: Snake B moves to (6,3)-(6,4), still stuck (shrunk to length 1)
|
|
logic.move_snakes()
|
|
assert snake_a.stuck is True
|
|
assert len(snake_a.body) == 1 # Shrunk to minimum
|
|
|
|
# Tick 3: Position (6,5) clear, Snake A finally unsticks!
|
|
logic.move_snakes()
|
|
assert snake_a.stuck is False
|
|
assert snake_a.get_head().x == 6 # Moved forward to (6,5)
|
|
|
|
def test_direction_change_unsticks(self) -> None:
|
|
"""Test changing direction to clear path unsticks snake."""
|
|
logic = GameLogic()
|
|
|
|
# Snake stuck against wall facing left
|
|
snake = Snake(player_id="player1", body=[
|
|
Position(0, 5),
|
|
Position(1, 5),
|
|
], direction=LEFT)
|
|
logic.state.snakes.append(snake)
|
|
|
|
# Get stuck
|
|
logic.move_snakes()
|
|
assert snake.stuck is True
|
|
|
|
# Change direction to up (clear path)
|
|
logic.update_snake_direction("player1", UP)
|
|
|
|
# Next move should unstick
|
|
logic.move_snakes()
|
|
assert snake.stuck is False
|
|
assert snake.get_head().y == 4 # Moved up
|
|
|
|
def test_two_snakes_head_to_head(self) -> None:
|
|
"""Test two snakes stuck head-to-head both shrink."""
|
|
logic = GameLogic()
|
|
|
|
snake_a = Snake(player_id="player_a", body=[
|
|
Position(5, 5),
|
|
Position(4, 5),
|
|
], direction=RIGHT)
|
|
snake_b = Snake(player_id="player_b", body=[
|
|
Position(6, 5),
|
|
Position(7, 5),
|
|
], direction=LEFT)
|
|
logic.state.snakes.extend([snake_a, snake_b])
|
|
|
|
logic.move_snakes()
|
|
|
|
# Both snakes should be stuck
|
|
assert snake_a.stuck is True
|
|
assert snake_b.stuck is True
|
|
# Both should shrink
|
|
assert len(snake_a.body) == 1
|
|
assert len(snake_b.body) == 1
|
|
|
|
def test_input_buffer_fills_to_three(self) -> None:
|
|
"""Test input buffer fills up to 3 directions."""
|
|
logic = GameLogic()
|
|
snake = logic.create_snake("player1")
|
|
logic.state.snakes.append(snake)
|
|
|
|
# Add 3 different directions
|
|
logic.update_snake_direction("player1", UP)
|
|
logic.update_snake_direction("player1", LEFT)
|
|
logic.update_snake_direction("player1", DOWN)
|
|
|
|
assert len(snake.input_buffer) == 3
|
|
assert snake.input_buffer == [UP, LEFT, DOWN]
|
|
|
|
def test_input_buffer_ignores_duplicates(self) -> None:
|
|
"""Test input buffer ignores duplicate inputs."""
|
|
logic = GameLogic()
|
|
snake = logic.create_snake("player1")
|
|
logic.state.snakes.append(snake)
|
|
|
|
logic.update_snake_direction("player1", UP)
|
|
logic.update_snake_direction("player1", UP) # Duplicate
|
|
|
|
assert len(snake.input_buffer) == 1
|
|
assert snake.input_buffer == [UP]
|
|
|
|
def test_input_buffer_opposite_replacement(self) -> None:
|
|
"""Test opposite direction replaces last in buffer."""
|
|
logic = GameLogic()
|
|
snake = logic.create_snake("player1")
|
|
logic.state.snakes.append(snake)
|
|
|
|
logic.update_snake_direction("player1", UP)
|
|
logic.update_snake_direction("player1", DOWN) # Opposite to UP
|
|
|
|
# DOWN should replace UP
|
|
assert len(snake.input_buffer) == 1
|
|
assert snake.input_buffer == [DOWN]
|
|
|
|
def test_input_buffer_overflow_replacement(self) -> None:
|
|
"""Test 4th input replaces last slot when buffer is full."""
|
|
logic = GameLogic()
|
|
snake = logic.create_snake("player1")
|
|
logic.state.snakes.append(snake)
|
|
|
|
# Fill buffer with 3 directions
|
|
logic.update_snake_direction("player1", UP)
|
|
logic.update_snake_direction("player1", LEFT)
|
|
logic.update_snake_direction("player1", DOWN)
|
|
|
|
# 4th input should replace last slot
|
|
logic.update_snake_direction("player1", RIGHT)
|
|
|
|
assert len(snake.input_buffer) == 3
|
|
assert snake.input_buffer == [UP, LEFT, RIGHT] # DOWN replaced by RIGHT
|
|
|
|
def test_input_buffer_consumption(self) -> None:
|
|
"""Test buffer is consumed during 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)
|
|
|
|
# Add direction to buffer
|
|
snake.input_buffer = [UP]
|
|
|
|
logic.move_snakes()
|
|
|
|
# Buffer should be consumed and direction applied
|
|
assert len(snake.input_buffer) == 0
|
|
assert snake.direction == UP
|
|
assert snake.get_head().y == 4 # Moved up
|
|
|
|
def test_input_buffer_skips_180_turn(self) -> None:
|
|
"""Test buffer skips 180-degree turns for multi-segment snakes."""
|
|
logic = GameLogic()
|
|
snake = Snake(player_id="player1", body=[
|
|
Position(5, 5),
|
|
Position(4, 5),
|
|
Position(3, 5),
|
|
], direction=RIGHT)
|
|
logic.state.snakes.append(snake)
|
|
|
|
# Buffer has opposite direction then valid direction
|
|
snake.input_buffer = [LEFT, UP] # LEFT is 180° from RIGHT
|
|
|
|
logic.move_snakes()
|
|
|
|
# LEFT should be skipped, UP should be applied
|
|
assert len(snake.input_buffer) == 0
|
|
assert snake.direction == UP
|
|
assert snake.get_head().y == 4 # Moved up
|
|
|
|
def test_zero_players_spawns_three_apples(self) -> None:
|
|
"""Test field populates with 3 apples when no players."""
|
|
logic = GameLogic()
|
|
logic.state.game_running = True
|
|
|
|
# Start with no snakes and no food
|
|
logic.state.snakes = []
|
|
logic.state.food = []
|
|
|
|
# Update should populate 3 apples
|
|
logic.update()
|
|
|
|
assert len(logic.state.food) == 3
|
|
|
|
# Subsequent updates should maintain 3 apples
|
|
logic.update()
|
|
assert len(logic.state.food) == 3
|
|
|