Files
claudePySnake/tests/test_game_logic.py
Vladyslav Doloman ce492b0dc2 Add input buffering, auto-start, and gameplay improvements
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>
2025-10-04 19:11:20 +03:00

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