GameService.java

package me.schawe.multijsnake.gamemanagement;

import me.schawe.multijsnake.gamemanagement.exceptions.InvalidMapException;
import me.schawe.multijsnake.gamemanagement.player.PlayerId;
import me.schawe.multijsnake.gamemanagement.player.PlayerInfo;
import me.schawe.multijsnake.gamemanagement.websocket.WebSocketService;
import me.schawe.multijsnake.snake.*;
import me.schawe.multijsnake.snake.ai.*;
import me.schawe.multijsnake.util.IdGenerator;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

@Service
@EnableScheduling
public class GameService {
    private final WebSocketService webSocketService;
    private final ApplicationEventPublisher applicationEventPublisher;

    private final ConcurrentHashMap<String, GameState> gameStateMap = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<PlayerId, PlayerInfo> playerInfoMap = new ConcurrentHashMap<>();

    private final Map<String, AutopilotDescription> aiDescriptionMap;

    private final Random random;

    public GameService(WebSocketService webSocketService, ApplicationEventPublisher applicationEventPublisher) {
        this.webSocketService = webSocketService;
        this.applicationEventPublisher = applicationEventPublisher;

        this.aiDescriptionMap = aiDescriptions();

        random = new Random();
    }

    private GameState newGame(int width, int height, String id) {
        GameState gameState = new GameState(width, height, id);
        return initGameState(gameState);
    }

    private GameState initGameState(GameState gameState) {
        int size = gameState.getWidth() * gameState.getHeight();
        gameState.setSnakeDiesCallback(x -> snakeDied(x, size));

        String gameId = gameState.getId();

        if(gameStateMap.containsKey(gameId)) {
            throw new InvalidMapException("This id '" + gameState.getId() + "' already exists!");
        }
        gameStateMap.put(gameId, gameState);

        return gameState;
    }

    @Scheduled(fixedRate = 300)
    public void periodicUpdate() {
        ArrayList<String> abandoned = new ArrayList<>();

        for (String id : allIds()) {
            GameState gameState = gameStateMap.get(id);
            if(gameState.isAbandoned()) {
                // can we remove the keys here directly? do we iterate over a copy of the key set?
                // better be safe and remove them after the iteration, in case it is
                // a reference, like apparently everything in Java
                abandoned.add(id);
                continue;
            }
            if(!gameState.isPaused() && !gameState.isGameOver()) {
                gameState.update();
                webSocketService.update(gameState);
            }
        }

        // now delete the abandoned instances
        for(String id : abandoned) {
            gameStateMap.remove(id);
        }
    }

    // TODO: this event should be thrown by `GameState`, but it is currently not part of the DI mechanism
    // TODO: I would need to declare `GameState` a Component/Bean with Prototype scope
    // TODO: and in turn can not instantiate myself (in tests, and especially in Python)
    // TODO: Therefore, I have to refactor the `GameState` and also this class to implement this properly
    private void snakeDied(Snake snake, int size) {
        SnakeDiesEvent snakeDiesEvent = new SnakeDiesEvent(this, snake, size);
        applicationEventPublisher.publishEvent(snakeDiesEvent);
    }

    public GameState idToGame(String id) {
        if(!gameStateMap.containsKey(id)) {
            throw new InvalidMapException(id);
        }

        return gameStateMap.get(id);
    }

    public void close(String id) {
        gameStateMap.remove(id);
    }

    private void registerPlayer(PlayerId playerId, PlayerInfo playerInfo) {
        playerInfoMap.put(playerId, playerInfo);
    }

    private SnakeId playerToSnake(PlayerId playerId) {
        if(!playerInfoMap.containsKey(playerId)) {
            throw new InvalidMapException("PlayerId: " + playerId.id());
        }

        return playerInfoMap.get(playerId).snakeId();
    }

    public Set<String> allIds() {
        return gameStateMap.keySet();
    }

    public void pause(PlayerId playerId) {
        SnakeId snakeId = playerToSnake(playerId);
        GameState state = idToGame(snakeId.getId());
        state.setPause(true);
        webSocketService.update(state);
    }

    public void unpause(PlayerId playerId) {
        SnakeId snakeId = playerToSnake(playerId);
        GameState state = idToGame(snakeId.getId());
        state.setPause(false);
        webSocketService.update(state);
    }

    public void reset(PlayerId playerId) {
        SnakeId snakeId = playerToSnake(playerId);
        GameState state = idToGame(snakeId.getId());
        state.reset();
        webSocketService.update(state);
    }

    public PlayerId joinNewGame(String sessionId, String id, int width, int height) {
        newGame(width, height, id);

        return join(sessionId, id);
    }

    public PlayerId join(String sessionId, String id) {
        // if the id does not exist, make it exist
        if(!gameStateMap.containsKey(id)) {
            newGame(20, 20, id);
        }

        SnakeId snakeId = idToGame(id).addSnake();
        String name = idToGame(id).getSnake(snakeId).getName();
        PlayerId playerId = new PlayerId(IdGenerator.gen(random));
        PlayerInfo playerInfo = new PlayerInfo(playerId, snakeId, sessionId, name);
        registerPlayer(playerId, playerInfo);
        GameState state = idToGame(id);

        webSocketService.update(state);
        webSocketService.updateHighscore(state.getWidth()*state.getHeight());
        webSocketService.updateGlobalHighscore();
        webSocketService.notifyJoined(playerInfo);

        return playerId;
    }

    public void move(PlayerId playerId, Move move) {
        SnakeId snakeId = playerToSnake(playerId);
        idToGame(snakeId.getId()).turn(snakeId, move);
    }

    public void setName(PlayerId playerId, String name) {
        SnakeId snakeId = playerToSnake(playerId);
        GameState state = idToGame(snakeId.getId());
        state.changeName(snakeId, name);

        webSocketService.update(state);
    }

    public void addAI(PlayerId playerId, String key) {
        SnakeId snakeId = playerToSnake(playerId);
        GameState state = idToGame(snakeId.getId());

        Autopilot autopilot = new AutopilotFactory().build(key);

        state.addAISnake(autopilot);

        webSocketService.update(state);
    }

    public static Map<String, AutopilotDescription> aiDescriptions() {
        return new AutopilotFactory().getAutopilots();
    }

    public List<AutopilotDescription> listAi() {
        return new ArrayList<>(aiDescriptionMap.values());
    }

    public Optional<PlayerInfo> findPlayerBySession(String sessionId) {
        return playerInfoMap.values().stream().filter(info -> info.sessionId().equals(sessionId)).findFirst();
    }
}