GameState.java

package me.schawe.multijsnake.snake;

import me.schawe.multijsnake.gamemanagement.exceptions.InvalidMapException;
import me.schawe.multijsnake.snake.ai.Autopilot;
import me.schawe.multijsnake.util.IdGenerator;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.function.Consumer;
import java.util.stream.Collectors;

public class GameState {
    private final String id;
    private final int width;
    private final int height;
    private Coordinate food;
    private final Map<SnakeId, Snake> snakes;
    private Set<Coordinate> occupationMap;
    private int score;
    private boolean paused;
    private boolean gameOver;
    private final List<SnakeId> toBeRemoved;
    // TODO: replace by event listener
    private Consumer<Snake> snakeDiesCallback;
    private final Random random;
    private int monotonousSnakeCounter;
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();

    public GameState(int width, int height, Random random, String id) {
        this.id = id;
        this.width = width;
        this.height = height;
        this.random = random;

        score = 0;
        snakes = new HashMap<>();
        occupationMap = new HashSet<>();
        toBeRemoved = new ArrayList<>();
        addFood();
        paused = true;
        gameOver = false;
        this.snakeDiesCallback = x -> {};

        monotonousSnakeCounter = 0;
    }

    public GameState(int width, int height, long seed) {
        this(width, height, new Random(seed), IdGenerator.gen(new Random(seed)));
    }

    // if we fix the id, derive the RNG state from this id.
    // this is handy for tests, but might be a bit surprising
    public GameState(int width, int height, String id) {
        this(width, height, new Random(id.hashCode()), id);
    }

    public GameState(int width, int height) {
        this(width, height, new Random(), IdGenerator.gen(new Random()));
    }

    public String getId() {
        return id;
    }

    public int getWidth() {
        return width;
    }

    public int getHeight() {
        return height;
    }

    public Coordinate getFood() {
        return food;
    }

    public int getScore() {
        return score;
    }

    public Map<Integer, Snake> getSnakes() {
        rwLock.readLock().lock();
        try {
            return snakes.entrySet().stream()
                    .collect(Collectors.toMap(entry -> entry.getKey().getIdx(), Map.Entry::getValue));
        } finally {
            rwLock.readLock().unlock();
        }
    }

    public Collection<Snake> getSnakeSet() {
        return snakes.values();
    }

    public Snake getSnake(SnakeId snakeId) {
        if (!snakeId.getId().equals(id)) {
            throw new InvalidMapException("snake " + snakeId + " does not live in GameState " + snakeId.getId());
        }

        return snakes.get(snakeId);
    }

    public boolean isPaused() {
        return paused;
    }

    public boolean isGameOver() {
        return gameOver;
    }

    public void setPause(boolean paused) {
        this.paused = paused;
    }

    public void setSnakeDiesCallback(Consumer<Snake> snakeDiesCallback) {
        this.snakeDiesCallback = snakeDiesCallback;
    }

    public void changeName(SnakeId id, String name) {
        snakes.get(id).setName(name);
    }

    public SnakeId addSnake() {
        return addSnake(randomUnoccupiedSite());
    }

    public SnakeId addSnake(Coordinate coordinate) {
        return addSnake(coordinate, Move.random(random));
    }

    public SnakeId addSnake(Coordinate coordinate, Move direction) {
        return addSnake(coordinate, direction, null);
    }

    public SnakeId addAISnake(Autopilot autopilot) {
        return addSnake(randomUnoccupiedSite(), Move.random(random), autopilot);
    }

    public SnakeId addSnake(Coordinate coordinate, Move direction, Autopilot autopilot) {
        rwLock.writeLock().lock();
        try {
            int idx = monotonousSnakeCounter++;
            SnakeId snakeId = new SnakeId(this.id, idx);
            Snake snake = new Snake(snakeId, coordinate, direction, autopilot);
            snakes.put(snakeId, snake);
            return snakeId;
        } finally {
            rwLock.writeLock().unlock();
        }
    }

    // signal if a site is occupied by either tail or head
    public boolean isOccupied(Coordinate site) {
        rwLock.readLock().lock();
        try {
            Set<Coordinate> heads = snakes.values().stream()
                    .map(Snake::getHead)
                    .collect(Collectors.toSet());
            return occupationMap.contains(site) || heads.contains(site);
        } finally {
            rwLock.readLock().unlock();
        }
    }

    // signal if a site is occupied for the specified snake by either tail or head, except its own head
    public boolean isOccupied(Coordinate site, Snake snake) {
        rwLock.readLock().lock();
        try {
            Set<Coordinate> otherHeads = snakes.values().stream()
                    .filter(s -> !s.equals(snake))
                    .map(Snake::getHead)
                    .collect(Collectors.toSet());

            return occupationMap.contains(site) || otherHeads.contains(site);
        } finally {
            rwLock.readLock().unlock();
        }
    }

    public boolean isWall(Coordinate coordinate) {
        rwLock.readLock().lock();
        try {
            return coordinate.getX() < 0
                    || coordinate.getX() >= width
                    || coordinate.getY() < 0
                    || coordinate.getY() >= height;
        } finally {
            rwLock.readLock().unlock();
        }
    }

    public boolean isEating(Snake snake) {
        rwLock.readLock().lock();
        try {
            return snake.getHead().equals(food);
        } finally {
            rwLock.readLock().unlock();
        }
    }

    private Coordinate randomUnoccupiedSite() {
        rwLock.readLock().lock();
        try {
            if(checkPerfectGame()) {
                // this should have been checked, and should therefore not happen
                throw new RuntimeException("Perfect Game!");
            }

            Coordinate site;
            do {
                site = randomSite();
            } while (isOccupied(site));
            return site;
        } finally {
            rwLock.readLock().unlock();
        }
    }

    private Coordinate randomSite() {
        return new Coordinate((int) (random.nextFloat() * width), (int) (random.nextFloat() * height));
    }

    public void addFood() {
        addFood(randomUnoccupiedSite());
    }

    public void addFood(Coordinate coordinate) {
        rwLock.writeLock().lock();
        try {
            food = coordinate;
        } finally {
            rwLock.writeLock().unlock();
        }
    }

    // TODO: call turn method on snake?
    public void turn(SnakeId id, Move move) {
        rwLock.writeLock().lock();
        try {
            Snake snake = getSnake(id);
            if(!snake.isDead()) {
                snake.setHeadDirection(
                        move.toNext(snake.getLastHeadDirection())
                            .orElse(snake.getHeadDirection())
                );
            }
        } finally {
            rwLock.writeLock().unlock();
        }
    }

    public boolean checkPerfectGame() {
        rwLock.readLock().lock();
        try {
            int occupied_fields = snakes.values().stream()
                    .map(snake -> snake.getLength() + 1)  // +1 for the heads
                    .mapToInt(Integer::intValue)
                    .sum();
            return occupied_fields == width * height - 1; // -1 to place new food
        } finally {
            rwLock.readLock().unlock();
        }
    }

    public void kill(SnakeId id) {
        rwLock.writeLock().lock();
        try {
            Snake snake = getSnake(id);
            // killing snakes twice would lead to double highscores
            if (!snake.isDead()) {
                snake.kill();
                snakeDiesCallback.accept(snake);
            }
        } finally {
            rwLock.writeLock().unlock();
        }
    }

    // check whether this game was abandoned by all human players
    public boolean isAbandoned() {
        rwLock.readLock().lock();
        try {
            long numActivePlayers = snakes.values().stream()
                    .filter(snake -> snake.ai().isEmpty() && !toBeRemoved.contains(snake.getId()))
                    .count();

            // games can only be created by joining, so if there are no active players, it is abandoned
            return numActivePlayers == 0;
        } finally {
            rwLock.readLock().unlock();
        }
    }

    public void markForRemoval(SnakeId id) {
        rwLock.writeLock().lock();
        try {
            toBeRemoved.add(id);
        } finally {
            rwLock.writeLock().unlock();
        }
    }

    public void reset() {
        rwLock.writeLock().lock();
        try {
            for(SnakeId snakeId : toBeRemoved) {
                snakes.remove(snakeId);
            }
            toBeRemoved.clear();

            for(Snake snake : snakes.values()) {
                snake.reset(randomSite());
            }
            score = 0;
            addFood();
            paused = true;
            gameOver = false;
        } finally {
            rwLock.writeLock().unlock();
        }
    }

    public void update() {
        rwLock.writeLock().lock();
        try {
            if(checkPerfectGame()) {
                gameOver = true;
            }

            if(gameOver) {
                return;
            }

            if(paused) {
                return;
            }

            for (Snake snake : snakes.values()) {
                if (snake.isDead()) {
                    continue;
                }

                snake.ai().ifPresent(autopilot -> snake.setHeadDirection(autopilot.suggest(this, snake)));

                if (isEating(snake)) {
                    addFood();
                    snake.incrementLength();
                    score += 1;
                }

                snake.step();
            }

            // update the occupation map after movement
            occupationMap = snakes.values().stream()
                .flatMap(snake -> snake.getTail().stream())
                .collect(Collectors.toSet());

            // check if any snakes stepped on occupied sites
            for (Snake snake : snakes.values()) {
                Coordinate head = snake.getHead();
                if (isWall(head) || isOccupied(head, snake)) {
                    kill(snake.getId());
                }
            }

            if(snakes.values().stream().allMatch(Snake::isDead)) {
                gameOver = true;
            }
        } finally {
            rwLock.writeLock().unlock();
        }
    }
}