From f78b884bb67682cd142d8e2b8696352020e41fbc Mon Sep 17 00:00:00 2001 From: Leonardo Moreno Date: Thu, 10 Sep 2026 18:51:11 -0300 Subject: [PATCH 1/5] fix: improvements and corrections --- README.md | 36 ++- src/main/java/com/mauadev/code/Handler.java | 142 +++----- src/main/java/com/mauadev/code/Logic.java | 128 ++++++++ .../java/com/mauadev/code/entities/Game.java | 14 + .../com/mauadev/code/entities/GameState.java | 45 +-- src/test/java/com/mauadev/HandlerTest.java | 304 ++++++++---------- src/test/java/com/mauadev/TestContext.java | 22 ++ 7 files changed, 380 insertions(+), 311 deletions(-) create mode 100644 src/main/java/com/mauadev/code/Logic.java create mode 100644 src/main/java/com/mauadev/code/entities/Game.java create mode 100644 src/test/java/com/mauadev/TestContext.java diff --git a/README.md b/README.md index 84f054d..010cae0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Ele foi escrito para rodar na AWS sem necessidade de servidor próprio, usando * ## 📦 Pré-requisitos -- **Java JDK** (versão 11 ou superior) +- **Java JDK** (versão 17 ou superior) [Download do JDK](https://www.oracle.com/java/technologies/javase-downloads.html) ou use o OpenJDK. - **Maven CLI** [Download Maven](https://maven.apache.org/download.cgi) @@ -45,7 +45,9 @@ O projeto segue a estrutura **Maven Standard Directory Layout**: │ │ │ └── com │ │ │ └── mauadev │ │ │ └── code -│ │ │ └── Handler.java # Código principal!!! +│ │ │ ├── Logic.java # ⭐ SEU ARQUIVO PRINCIPAL +│ │ │ ├── Handler.java # Ponte Lambda ↔ Logic (não mexa) +│ │ │ └── entities/ # Modelos de dados (não mexa) │ │ └── resources │ │ └── (se necessário, arquivos de configuração) │ └── test @@ -54,6 +56,16 @@ O projeto segue a estrutura **Maven Standard Directory Layout**: --- +## ⭐ Onde implementar sua snake + +| | Arquivo | O que fazer | +|---|---|---| +| ✅ **EDITE** | `src/main/java/com/mauadev/code/Logic.java` | **Este é o seu arquivo principal.** Implemente toda a inteligência da cobra aqui. | +| 🚫 Não altere | `Handler.java` | Roteador HTTP entre o API Gateway e a sua lógica. | +| 🚫 Não altere | `entities/` | Modelos de dados (GameState, Board, Snake…). | +| 🚫 Não altere | `.github/`, Terraform, infraestrutura | Deploy automático — já configurado. | + + ## ⚙️ Dependências (`pom.xml`) O `pom.xml`, nosso arquivo de dependencias, padrão para rodar este projeto: @@ -232,6 +244,7 @@ Assim que o Terraform concluir o deploy, será criada automaticamente uma URL p 5. Salve as configurações 6. Agora você pode testar a cobra nos jogos e desafios! +Ou acesse a [Arena Mauá](https://arena.devmaua.com) para competir com outros alunos! --- @@ -246,7 +259,7 @@ O `Handler.java` implementa a interface do **AWS Lambda RequestHandler**, recebe - Retorna resposta JSON ou erro 404 - **`handleInfo()`** → informações da cobra (cor, cabeça, cauda, autor, versão da API) - **`handleStart()`** → chamado no início do jogo (log inicial) -- **`handleMove()`** → lógica da jogada (neste exemplo, sempre "up") +- **`handleMove()`** → repassa para `Logic.getMove()`, que decide o movimento - **`handleEnd()`** → chamado no final do jogo (log final) - Usa **Gson** para converter objetos Java para JSON - Usa **APIGatewayProxyRequestEvent** e **APIGatewayProxyResponseEvent** para receber/enviar dados @@ -276,6 +289,21 @@ mvn test ## 📌 Observações -- O método `handleMove()` deve conter a inteligência do jogo. +- O método `Logic.getMove()` deve conter a inteligência do jogo. - O uso do **maven-shade-plugin** é obrigatório para empacotar todas as dependências no JAR final. - **EVITE** adicionar mais dependencias, pois o shade plugin empacota **todas as dependencias** em um JAR, tornando-o **talvez muito pesado** para o deploy!!! + +--- + +## 📈 Progressão pedagógica + +Use esta tabela como roteiro para evoluir a inteligência da sua cobra: + +| Nível | Nome | O que implementar | +|---|---|---| +| 0 | **Random** | movimento aleatório (já vem pronto) | +| 1 | **Don't Die** | não voltar, não bater na parede, não bater em si mesmo | +| 2 | **Food** | procurar comida | +| 3 | **Space** | avaliar espaço disponível, evitar becos | +| 4 | **Opponents** | considerar outras cobras, head-to-head | +| 5 | **Advanced** | BFS, flood fill, A*, avaliação de território | diff --git a/src/main/java/com/mauadev/code/Handler.java b/src/main/java/com/mauadev/code/Handler.java index 162595e..631ab1a 100644 --- a/src/main/java/com/mauadev/code/Handler.java +++ b/src/main/java/com/mauadev/code/Handler.java @@ -1,23 +1,29 @@ -package com.mauadev.code; +package com.mauadev.code; + +// Ponte entre o AWS Lambda e a logica da sua cobra. +// +// Voce NAO precisa mexer aqui. Este arquivo recebe o evento do API Gateway, +// descobre qual rota do Battlesnake foi chamada e repassa para Logic.java. +// +// Rotas da API (https://docs.battlesnake.com/api): +// GET / -> aparencia da cobra +// POST /start -> a partida comecou +// POST /move -> escolha a jogada deste turno +// POST /end -> a partida acabou import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.google.gson.Gson; -import com.mauadev.code.entities.Board; -import com.mauadev.code.entities.Coordinate; import com.mauadev.code.entities.GameState; -import com.mauadev.code.entities.Snake; import java.util.Collections; -import java.util.HashMap; import java.util.Map; public class Handler implements RequestHandler { - // Gson é uma biblioteca para converter objetos Java para JSON e vice-versa. - private static final Gson gson = new Gson(); + private static final Gson GSON = new Gson(); @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { @@ -26,134 +32,68 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re Object responseBody = null; try { - // Roteador para os diferentes endpoints da API BattleSnake switch (path) { case "/": - // Informações da sua cobra - responseBody = handleInfo(); + responseBody = Logic.info(); break; case "/start": - // Lógica para o início do jogo handleStart(request, context); break; case "/move": - // Lógica para decidir o próximo movimento responseBody = handleMove(request, context); break; case "/end": - // Lógica para o fim do jogo handleEnd(request, context); break; default: - // Se a rota não for encontrada, retorna um erro 404 - // Precisamos passar \ antes das aspas para não dar erro quando convertemos pra json - return response.withStatusCode(404).withBody("{\"error\": \"Path not found\"}"); + return response + .withStatusCode(404) + .withBody(GSON.toJson(Map.of("error", "Path not found: " + path))); } - // Configura a resposta de sucesso response.setStatusCode(200); response.setHeaders(Collections.singletonMap("Content-Type", "application/json")); if (responseBody != null) { - // Converte o objeto de resposta para uma string JSON - response.setBody(gson.toJson(responseBody)); + response.setBody(GSON.toJson(responseBody)); } } catch (Exception e) { - // Em caso de erro em qualquer parte da lógica context.getLogger().log("ERROR: " + e.getMessage()); response.setStatusCode(500); - response.setBody(String.format("{\"error\": \"%s\"}", e.getMessage())); + response.setBody(GSON.toJson(Map.of("error", e.getMessage()))); } return response; } - /** - * Responde ao endpoint / com as informações da sua cobra. 🐍 - */ - private Map handleInfo() { - Map info = new HashMap<>(); - info.put("apiversion", "1"); - info.put("author", "Leo"); - info.put("color", "#888888"); // Ex: Cinza - info.put("head", "default"); - info.put("tail", "default"); - return info; - } - - /** - * Chamado no início de cada jogo. Não precisa retornar nada. - */ + /** POST /start */ private void handleStart(APIGatewayProxyRequestEvent request, Context context) { - // Você pode usar o corpo da requisição (request.getBody()) para obter o estado inicial do jogo. - context.getLogger().log("Game Started!"); + GameState state = parseState(request); + context.getLogger().log("JOGO COMECOU (partida " + (state != null && state.getGame() != null ? state.getGame().getId() : "?") + ")"); + if (state != null) Logic.start(state); } - /** - * Chamado a cada turno para decidir o movimento. 🕹️ - */ + /** POST /move */ private Map handleMove(APIGatewayProxyRequestEvent request, Context context) { - // AQUI VAI A LÓGICA DA SUA COBRA! - // O corpo da requisição (request.getBody()) contém o estado atual do tabuleiro. - // Você deve analisá-lo para tomar uma decisão inteligente. - - // Exemplo de um objeto tabuleiro: - // { - // "height": 11, - // "width": 11, - // "food": [ - // {"x": 5, "y": 5}, - // {"x": 9, "y": 0}, - // {"x": 2, "y": 6} - // ], - // "hazards": [ - // {"x": 0, "y": 0}, - // {"x": 0, "y": 1}, - // {"x": 0, "y": 2} - // ], - // "snakes": [ - // {"id": "snake-one", ... }, - // {"id": "snake-two", ... }, - // {"id": "snake-three", ... } - // ] - // } - - // O tabuleiro deve ser acessado usando: - // Type mapType = new TypeToken>() {}.getType(); - // Map body = gson.fromJson(response.getBody(), mapType); - - // Exemplo de lógica muito simples: sempre se mover para cima. - // CUIDADO: Isso fará sua cobra bater na parede rapidamente! - - String requestBody = request.getBody(); - GameState gameState = gson.fromJson(requestBody, GameState.class); - Board board = gameState.getBoard(); - Snake you = gameState.getYou(); - - System.out.println("--- Turno: " + gameState.getTurn() + " ---"); - System.out.println("Minha vida: " + you.getHealth()); - System.out.println("Posição da minha cabeça: (" + you.getHead().getX() + ", " + you.getHead().getY() + ")"); - - if (!board.getFood().isEmpty()) { - Coordinate firstFood = board.getFood().get(0); - System.out.println("Comida mais próxima em: (" + firstFood.getX() + ", " + firstFood.getY() + ")"); - } - - // Lógica do movimento (exemplo simples) - // Agora que você tem o estado do jogo, pode implementar uma lógica mais inteligente aqui. - - Map move = new HashMap<>(); - move.put("move", "up"); - move.put("shout", "Estou indo para cima!"); // Opcional - - return move; + GameState state = parseState(request); + String chosen = (state != null) ? Logic.getMove(state) : "up"; + context.getLogger().log("MOVE " + (state != null ? state.getTurn() : "?") + ": " + chosen); + return Map.of("move", chosen); } - /** - * Chamado no final de cada jogo. Não precisa retornar nada. - */ + /** POST /end */ private void handleEnd(APIGatewayProxyRequestEvent request, Context context) { - // Você pode analisar a requisição para saber se venceu ou perdeu. - context.getLogger().log("Game Ended!"); + GameState state = parseState(request); + context.getLogger().log("FIM DE JOGO apos " + (state != null ? state.getTurn() : "?") + " turnos"); + if (state != null) Logic.end(state); + } + + /** Desserializa o corpo da requisicao como GameState. Retorna null se falhar. */ + private GameState parseState(APIGatewayProxyRequestEvent request) { + try { + return GSON.fromJson(request.getBody(), GameState.class); + } catch (Exception e) { + return null; + } } } \ No newline at end of file diff --git a/src/main/java/com/mauadev/code/Logic.java b/src/main/java/com/mauadev/code/Logic.java new file mode 100644 index 0000000..cc38fdb --- /dev/null +++ b/src/main/java/com/mauadev/code/Logic.java @@ -0,0 +1,128 @@ +package com.mauadev.code; + +// Bem-vindo ao +// __________ __ __ .__ __ +// \______ \_____ _/ |__/ |_| | ____ ______ ____ _____ | | __ ____ +// | | _/\__ \ __\ __\ | _/ __ \ / ___// \__ \ | |/ // __ \ +// | | \ / __ \| | | | | |_\ ___/ \___ \| | \/ __ \| <\ ___/ +// |________/(______/__| |__| |____/\_____>______>___|__(______/__|__\_____> +// +// ESTE E O ARQUIVO QUE VOCE VAI EDITAR. Todo o resto do projeto existe +// so para levar o estado do jogo ate as quatro funcoes abaixo. +// +// Para comecar, ja deixamos pronta a logica que impede a sua cobra de andar +// para tras (ela morreria na hora). Os TODOs marcam os proximos passos. +// Documentacao: https://docs.battlesnake.com + +import com.mauadev.code.entities.Coordinate; +import com.mauadev.code.entities.GameState; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +/** + * Logica da cobra. E AQUI que voce programa a inteligencia da snake. + *

+ * GET / -> {@link #info()}
+ * POST /start -> {@link #start(GameState)}
+ * POST /move -> {@link #getMove(GameState)} foco principal
+ * POST /end -> {@link #end(GameState)} + */ +public class Logic { + + private static final Random RANDOM = new Random(); + + /** + * GET / - chamado quando voce cadastra a cobra e a cada partida. + * Controla a aparencia dela. Opcoes de cabeca, cauda e cor: + * https://docs.battlesnake.com/guides/customizations + */ + public static java.util.Map info() { + java.util.Map info = new java.util.HashMap<>(); + info.put("apiversion", "1"); + info.put("author", ""); // TODO: coloque aqui o SEU usuario do Battlesnake + info.put("color", "#8B0000"); // TODO: escolha a cor da sua cobra + info.put("head", "tiger-king"); // TODO: escolha a cabeca + info.put("tail", "hook"); // TODO: escolha a cauda + return info; + } + + /** + * POST /start - chamado uma vez, quando a partida comeca. + * Bom lugar para preparar qualquer estado inicial. + */ + public static void start(GameState state) { + // state.getTurn(), state.getBoard(), state.getYou() estao disponiveis + } + + /** + * POST /end - chamado uma vez, quando a partida termina. + */ + public static void end(GameState state) { + // Voce pode analisar o estado final para saber se venceu ou perdeu. + } + + /** + * POST /move - chamado a cada turno. Aqui mora a inteligencia da sua cobra. + * Deve retornar "up", "down", "left" ou "right". + * Exemplo do JSON recebido: https://docs.battlesnake.com/api/example-move + * + * @param state estado atual do jogo + * @return direcao escolhida + */ + public static String getMove(GameState state) { + List isMoveSafe = new ArrayList<>(Arrays.asList("up", "down", "left", "right")); + + // --- Impedir que a cobra ande para tras (ja implementado) --- + // O pescoco e a parte do corpo logo atras da cabeca. Voltar por cima + // dele e morte certa, entao removemos aquela direcao da lista. + Coordinate myHead = state.getYou().getHead(); + List body = state.getYou().getBody(); + + if (body != null && body.size() >= 2) { + Coordinate myNeck = body.get(1); + + if (myNeck.getX() < myHead.getX()) { + // pescoco a esquerda da cabeca -> nao va para a esquerda + isMoveSafe.remove("left"); + } else if (myNeck.getX() > myHead.getX()) { + // pescoco a direita da cabeca -> nao va para a direita + isMoveSafe.remove("right"); + } else if (myNeck.getY() < myHead.getY()) { + // pescoco abaixo da cabeca -> nao desca + isMoveSafe.remove("down"); + } else if (myNeck.getY() > myHead.getY()) { + // pescoco acima da cabeca -> nao suba + isMoveSafe.remove("up"); + } + } + + // TODO: Passo 1 - impedir que a cobra saia do tabuleiro + // int boardWidth = state.getBoard().getWidth(); + // int boardHeight = state.getBoard().getHeight(); + + // TODO: Passo 2 - impedir que a cobra bata no proprio corpo + // List myBody = state.getYou().getBody(); + + // TODO: Passo 3 - impedir que a cobra bata nas adversarias + // List opponents = state.getBoard().getSnakes(); + + // Sobrou alguma direcao segura? + if (isMoveSafe.isEmpty()) { + // Emergencia: todas as direcoes sao perigosas. + // Escolhemos uma ao acaso entre as 4 - melhor do que travar. + List allMoves = Arrays.asList("up", "down", "left", "right"); + return allMoves.get(RANDOM.nextInt(allMoves.size())); + } + + // Escolhe uma direcao segura ao acaso. + String chosen = isMoveSafe.get(RANDOM.nextInt(isMoveSafe.size())); + + // TODO: Passo 4 - ir atras da comida em vez de sortear, para nao morrer de fome + // List food = state.getBoard().getFood(); + + return chosen; + } +} \ No newline at end of file diff --git a/src/main/java/com/mauadev/code/entities/Game.java b/src/main/java/com/mauadev/code/entities/Game.java new file mode 100644 index 0000000..789702d --- /dev/null +++ b/src/main/java/com/mauadev/code/entities/Game.java @@ -0,0 +1,14 @@ +package com.mauadev.code.entities; + +/** Metadados da partida. */ +public class Game { + private String id; + private int timeout; + + public Game() {} + + public String getId() { return id; } + public int getTimeout() { return timeout; } + public void setId(String id) { this.id = id; } + public void setTimeout(int timeout) { this.timeout = timeout; } +} \ No newline at end of file diff --git a/src/main/java/com/mauadev/code/entities/GameState.java b/src/main/java/com/mauadev/code/entities/GameState.java index 0efef52..7f01fe2 100644 --- a/src/main/java/com/mauadev/code/entities/GameState.java +++ b/src/main/java/com/mauadev/code/entities/GameState.java @@ -1,41 +1,22 @@ -package com.mauadev.code.entities; +package com.mauadev.code.entities; +/** O pacote completo que chega em /start, /move e /end. */ public class GameState { - // Atributos do estado do jogo - + private Game game; private int turn; private Board board; private Snake you; - public GameState(){ - } - - public GameState( - String turn, - Board board, - Snake you - ){ - this.turn = Integer.parseInt(turn); - this.board = board; - this.you = you; - } + public GameState() {} - public int getTurn() { - return turn; - } - public Board getBoard() { - return board; - } - public Snake getYou() { - return you; - } + public Game getGame() { return game; } + public int getTurn() { return turn; } + public Board getBoard() { return board; } + public Snake getYou() { return you; } - public void setTurn(int turn) { - this.turn = turn; - } - public void setBoard(Board board) { - this.board = board; - } - -} + public void setGame(Game game) { this.game = game; } + public void setTurn(int turn) { this.turn = turn; } + public void setBoard(Board board) { this.board = board; } + public void setYou(Snake you) { this.you = you; } +} \ No newline at end of file diff --git a/src/test/java/com/mauadev/HandlerTest.java b/src/test/java/com/mauadev/HandlerTest.java index 19c8ed8..81bfc04 100644 --- a/src/test/java/com/mauadev/HandlerTest.java +++ b/src/test/java/com/mauadev/HandlerTest.java @@ -1,58 +1,84 @@ -package com.mauadev; +package com.mauadev; import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.LambdaLogger; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; +import com.mauadev.code.Handler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.lang.reflect.Type; +import java.util.List; import java.util.Map; -import com.mauadev.code.Handler; - import static org.junit.jupiter.api.Assertions.*; /** - * Testes unitários para a classe Handler. + * Testes do Handler - percorrem o caminho completo + * API Gateway -> Handler -> Logic. */ public class HandlerTest { + private static final List DIRECOES = List.of("up", "down", "left", "right"); + private static final Gson GSON = new Gson(); + private Handler handler; - private Context testContext; - private Gson gson; + private Context context; - // @BeforeEach garante que este método seja executado antes de cada @Test @BeforeEach public void setUp() { handler = new Handler(); - gson = new Gson(); - // Criamos um Context de teste "mockado" para passar para o handler. - testContext = new TestContext(); + context = new TestContext(); } - @Test - @DisplayName("Teste da rota / (Info) - Deve retornar informações da cobra com status 200") - public void testHandleInfo_shouldReturnSnakeInfo() { - // Arrange: Prepara a requisição para a rota "/" - APIGatewayProxyRequestEvent request = new APIGatewayProxyRequestEvent().withPath("/"); + // --- Helpers --- + + /** Monta um estado de jogo minimo com cabeca e pescoco. */ + private String gameStateJson(int hx, int hy, int nx, int ny) { + return String.format(""" + { + "game": {"id": "partida-de-teste", "timeout": 500}, + "turn": 4, + "board": { + "height": 11, "width": 11, + "food": [{"x": 5, "y": 5}], + "hazards": [], + "snakes": [] + }, + "you": { + "id": "minha-cobra", "name": "MinhaCobra", "health": 100, + "body": [{"x": %d, "y": %d}, {"x": %d, "y": %d}, {"x": %d, "y": %d}], + "head": {"x": %d, "y": %d}, + "length": 3 + } + } + """, hx, hy, nx, ny, nx, ny - 1, hx, hy); + } - // Act: Executa o método a ser testado - APIGatewayProxyResponseEvent response = handler.handleRequest(request, testContext); + private APIGatewayProxyRequestEvent request(String path, String body) { + APIGatewayProxyRequestEvent req = new APIGatewayProxyRequestEvent(); + req.setPath(path); + req.setBody(body); + return req; + } - // Assert: Verifica os resultados - assertEquals(200, response.getStatusCode()); - assertEquals("application/json", response.getHeaders().get("Content-Type")); - assertNotNull(response.getBody()); + private Map parseBody(String json) { + Type type = new TypeToken>() {}.getType(); + return GSON.fromJson(json, type); + } - // Analisa o corpo JSON para verificar seu conteúdo - Type mapType = new TypeToken>() {}.getType(); - Map body = gson.fromJson(response.getBody(), mapType); + // --- T1: info retorna campos obrigatorios --- + @Test + @DisplayName("T1 - GET / retorna os campos obrigatorios da cobra") + public void testInfo_retornaCamposObrigatorios() { + APIGatewayProxyResponseEvent response = handler.handleRequest(request("/", null), context); + + assertEquals(200, response.getStatusCode()); + Map body = parseBody(response.getBody()); assertEquals("1", body.get("apiversion")); assertNotNull(body.get("author")); assertNotNull(body.get("color")); @@ -60,173 +86,103 @@ public void testHandleInfo_shouldReturnSnakeInfo() { assertNotNull(body.get("tail")); } + // --- T2: move retorna direcao valida --- + @Test - @DisplayName("Teste da rota /start - Deve retornar status 200 OK sem corpo") - public void testHandleStart_shouldReturn200OK() { - // Arrange - APIGatewayProxyRequestEvent request = new APIGatewayProxyRequestEvent().withPath("/start"); - // O corpo da requisição pode ser um JSON com o estado do jogo, mas para este teste não é necessário - request.setBody("{}"); + @DisplayName("T2 - POST /move retorna uma direcao valida") + public void testMove_retornaDirecaoValida() { + String stateJson = gameStateJson(5, 4, 4, 4); + + for (int i = 0; i < 50; i++) { + APIGatewayProxyResponseEvent response = handler.handleRequest(request("/move", stateJson), context); + assertEquals(200, response.getStatusCode()); + Map body = parseBody(response.getBody()); + assertTrue(DIRECOES.contains(body.get("move")), + "Direcao invalida: " + body.get("move")); + } + } - // Act - APIGatewayProxyResponseEvent response = handler.handleRequest(request, testContext); + // --- T3: nunca volta contra o pescoco --- - // Assert - assertEquals(200, response.getStatusCode()); - // O handler para /start não define um corpo de resposta, então ele pode ser nulo ou vazio - assertTrue(response.getBody() == null || response.getBody().isEmpty()); + @Test + @DisplayName("T3a - nao volta para a esquerda quando pescoco esta a esquerda") + public void testMove_naoVoltaEsquerda() { + String state = gameStateJson(5, 4, 4, 4); // pescoco a esquerda + for (int i = 0; i < 50; i++) { + APIGatewayProxyResponseEvent resp = handler.handleRequest(request("/move", state), context); + assertNotEquals("left", parseBody(resp.getBody()).get("move")); + } } @Test - @DisplayName("Teste da rota /move - Deve se mover para cima em direção à comida") - public void testHandleMove_withBoardData_shouldMoveUpTowardsFood() { - // Arrange 🐍 - APIGatewayProxyRequestEvent request = new APIGatewayProxyRequestEvent().withPath("/move"); - - String jsonRequestBody = """ - { - "game": { - "id": "game-id-123", - "ruleset": {"name": "standard", "version": "v1.2.3"}, - "timeout": 500 - }, - "turn": 4, - "board": { - "height": 11, - "width": 11, - "food": [ - {"x": 5, "y": 5}, - {"x": 9, "y": 0}, - {"x": 2, "y": 6} - ], - "hazards": [ - {"x": 3, "y": 3} - ], - "snakes": [ - { - "id": "snake-id-yours", - "name": "MySnake", - "health": 90, - "body": [{"x": 5, "y": 4}, {"x": 4, "y": 4}, {"x": 3, "y": 4}], - "head": {"x": 5, "y": 4}, - "length": 3, - "shout": "Going for food!" - }, - { - "id": "snake-id-opponent", - "name": "OpponentSnake", - "health": 95, - "body": [{"x": 1, "y": 1}, {"x": 1, "y": 2}, {"x": 1, "y": 3}], - "head": {"x": 1, "y": 1}, - "length": 3, - "shout": "Hisss" - } - ] - }, - "you": { - "id": "snake-id-yours", - "name": "MySnake", - "health": 50, - "body": [{"x": 6, "y": 4}, {"x": 4, "y": 4}, {"x": 3, "y": 4}], - "head": {"x": 6, "y": 4}, - "length": 3, - "shout": "Going for food!" - } + @DisplayName("T3b - nao volta para a direita quando pescoco esta a direita") + public void testMove_naoVoltaDireita() { + String state = gameStateJson(5, 4, 6, 4); // pescoco a direita + for (int i = 0; i < 50; i++) { + APIGatewayProxyResponseEvent resp = handler.handleRequest(request("/move", state), context); + assertNotEquals("right", parseBody(resp.getBody()).get("move")); } - """; - - request.setBody(jsonRequestBody); - - // Act 🎯 - APIGatewayProxyResponseEvent response = handler.handleRequest(request, testContext); - - // Assert ✅ - assertEquals(200, response.getStatusCode(), "O status code da resposta deve ser 200"); - assertNotNull(response.getBody(), "O corpo da resposta não pode ser nulo"); - - Type mapType = new TypeToken>() {}.getType(); - Map body = gson.fromJson(response.getBody(), mapType); - - // Aqui você verifica se o seu algorítmo está realizando o movimento certo, alinhado com a lógica implementada. - // Nesse caso, a lógica será sempre up pois a configuração em Handler.java está fixa. - assertEquals("up", body.get("move"), "O movimento esperado era 'up' em direção à comida"); } + @Test + @DisplayName("T3c - nao desce quando pescoco esta abaixo") + public void testMove_naoDesce() { + String state = gameStateJson(5, 4, 5, 3); // pescoco abaixo + for (int i = 0; i < 50; i++) { + APIGatewayProxyResponseEvent resp = handler.handleRequest(request("/move", state), context); + assertNotEquals("down", parseBody(resp.getBody()).get("move")); + } + } @Test - @DisplayName("Teste da rota /end - Deve retornar status 200 OK sem corpo") - public void testHandleEnd_shouldReturn200OK() { - // Arrange - APIGatewayProxyRequestEvent request = new APIGatewayProxyRequestEvent().withPath("/end"); - request.setBody("{}"); + @DisplayName("T3d - nao sobe quando pescoco esta acima") + public void testMove_naoSobe() { + String state = gameStateJson(5, 4, 5, 5); // pescoco acima + for (int i = 0; i < 50; i++) { + APIGatewayProxyResponseEvent resp = handler.handleRequest(request("/move", state), context); + assertNotEquals("up", parseBody(resp.getBody()).get("move")); + } + } - // Act - APIGatewayProxyResponseEvent response = handler.handleRequest(request, testContext); + // --- T6: comportamento sem safe moves (nao deve lancar excecao) --- - // Assert + @Test + @DisplayName("T6 - comportamento definido quando nao ha safe moves") + public void testMove_comportamentoSemSafeMoves() { + // Cabeca no canto, pescoco acima, corpo a direita + String stateJson = """ + { + "game": {"id": "teste", "timeout": 500}, + "turn": 1, + "board": {"height": 11, "width": 11, "food": [], "hazards": [], "snakes": []}, + "you": { + "id": "s1", "name": "cobra", "health": 100, + "body": [{"x": 0, "y": 0}, {"x": 0, "y": 1}, {"x": 1, "y": 0}], + "head": {"x": 0, "y": 0}, "length": 3 + } + } + """; + APIGatewayProxyResponseEvent response = handler.handleRequest(request("/move", stateJson), context); assertEquals(200, response.getStatusCode()); - assertTrue(response.getBody() == null || response.getBody().isEmpty()); + Map body = parseBody(response.getBody()); + assertTrue(DIRECOES.contains(body.get("move")), "Direcao invalida: " + body.get("move")); } - @Test - @DisplayName("Teste de rota inválida - Deve retornar erro 404 Not Found") - public void testInvalidPath_shouldReturn404NotFound() { - // Arrange - APIGatewayProxyRequestEvent request = new APIGatewayProxyRequestEvent().withPath("/caminho-que-nao-existe"); - - // Act - APIGatewayProxyResponseEvent response = handler.handleRequest(request, testContext); - - // Assert - assertEquals(404, response.getStatusCode()); - assertNotNull(response.getBody()); - - Type mapType = new TypeToken>() {}.getType(); - Map body = gson.fromJson(response.getBody(), mapType); - - assertEquals("Path not found", body.get("error")); - } + // --- start e end retornam 200 --- - /** - * Uma implementação simples da interface Context para fins de teste. - * Ela fornece um logger que imprime no console, evitando NullPointerException. - */ - private static class TestContext implements Context { - @Override - public String getAwsRequestId() { return "test-request-id"; } - @Override - public String getLogGroupName() { return "test-log-group"; } - @Override - public String getLogStreamName() { return "test-log-stream"; } - @Override - public String getFunctionName() { return "test-function"; } - @Override - public String getFunctionVersion() { return "1.0"; } - @Override - public String getInvokedFunctionArn() { return "arn:aws:lambda:us-east-1:123456789012:function:test-function"; } - @Override - public com.amazonaws.services.lambda.runtime.CognitoIdentity getIdentity() { return null; } - @Override - public com.amazonaws.services.lambda.runtime.ClientContext getClientContext() { return null; } - @Override - public int getRemainingTimeInMillis() { return 30000; } - @Override - public int getMemoryLimitInMB() { return 512; } - @Override - public LambdaLogger getLogger() { - // Retorna um logger simples que imprime no console durante o teste. - return new TestLogger(); - } + @Test + @DisplayName("POST /start retorna 200") + public void testStart_retorna200() { + String state = gameStateJson(5, 4, 4, 4); + APIGatewayProxyResponseEvent response = handler.handleRequest(request("/start", state), context); + assertEquals(200, response.getStatusCode()); } - private static class TestLogger implements LambdaLogger { - @Override - public void log(String message) { - System.out.println(message); - } - @Override - public void log(byte[] message) { - System.out.println(new String(message)); - } + @Test + @DisplayName("POST /end retorna 200") + public void testEnd_retorna200() { + String state = gameStateJson(5, 4, 4, 4); + APIGatewayProxyResponseEvent response = handler.handleRequest(request("/end", state), context); + assertEquals(200, response.getStatusCode()); } } \ No newline at end of file diff --git a/src/test/java/com/mauadev/TestContext.java b/src/test/java/com/mauadev/TestContext.java new file mode 100644 index 0000000..5c3d196 --- /dev/null +++ b/src/test/java/com/mauadev/TestContext.java @@ -0,0 +1,22 @@ +package com.mauadev; + +import com.amazonaws.services.lambda.runtime.ClientContext; +import com.amazonaws.services.lambda.runtime.CognitoIdentity; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.LambdaLogger; + +public class TestContext implements Context { + @Override public String getAwsRequestId() { return "test-request-id"; } + @Override public String getLogGroupName() { return "/aws/lambda/test"; } + @Override public String getLogStreamName() { return "test-stream"; } + @Override public String getFunctionName() { return "test-function"; } + @Override public String getFunctionVersion() { return "1"; } + @Override public String getInvokedFunctionArn() { return "arn:test"; } + @Override public CognitoIdentity getIdentity() { return null; } + @Override public ClientContext getClientContext() { return null; } + @Override public int getRemainingTimeInMillis() { return 5000; } + @Override public int getMemoryLimitInMB() { return 512; } + @Override public LambdaLogger getLogger() { + return message -> System.out.println("[LAMBDA] " + message); + } +} \ No newline at end of file From 593958f6564780b073baae3554dc81f5585999b1 Mon Sep 17 00:00:00 2001 From: Leonardo Moreno Date: Thu, 10 Sep 2026 19:16:25 -0300 Subject: [PATCH 2/5] fix: minor fixes --- src/main/java/com/mauadev/code/Handler.java | 2 +- src/main/java/com/mauadev/code/Logic.java | 44 ++++++++++++++--- .../java/com/mauadev/code/entities/Game.java | 2 +- .../com/mauadev/code/entities/GameState.java | 2 +- src/test/java/com/mauadev/HandlerTest.java | 49 ++++++++++++++++++- src/test/java/com/mauadev/TestContext.java | 2 +- 6 files changed, 88 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/mauadev/code/Handler.java b/src/main/java/com/mauadev/code/Handler.java index 631ab1a..8485d4c 100644 --- a/src/main/java/com/mauadev/code/Handler.java +++ b/src/main/java/com/mauadev/code/Handler.java @@ -1,4 +1,4 @@ -package com.mauadev.code; +package com.mauadev.code; // Ponte entre o AWS Lambda e a logica da sua cobra. // diff --git a/src/main/java/com/mauadev/code/Logic.java b/src/main/java/com/mauadev/code/Logic.java index cc38fdb..d432e13 100644 --- a/src/main/java/com/mauadev/code/Logic.java +++ b/src/main/java/com/mauadev/code/Logic.java @@ -1,4 +1,4 @@ -package com.mauadev.code; +package com.mauadev.code; // Bem-vindo ao // __________ __ __ .__ __ @@ -97,16 +97,44 @@ public static String getMove(GameState state) { // pescoco acima da cabeca -> nao suba isMoveSafe.remove("up"); } - } + // 2. Impedir que a cobra saia do tabuleiro (paredes) + if (state.getBoard() != null) { + int boardWidth = state.getBoard().getWidth(); + int boardHeight = state.getBoard().getHeight(); - // TODO: Passo 1 - impedir que a cobra saia do tabuleiro - // int boardWidth = state.getBoard().getWidth(); - // int boardHeight = state.getBoard().getHeight(); + if (myHead.getX() + 1 >= boardWidth) { + isMoveSafe.remove("right"); + } + if (myHead.getX() - 1 < 0) { + isMoveSafe.remove("left"); + } + if (myHead.getY() + 1 >= boardHeight) { + isMoveSafe.remove("up"); + } + if (myHead.getY() - 1 < 0) { + isMoveSafe.remove("down"); + } + } - // TODO: Passo 2 - impedir que a cobra bata no proprio corpo - // List myBody = state.getYou().getBody(); + // 3. Impedir que a cobra bata no próprio corpo + if (body != null) { + for (Coordinate segment : body) { + if (segment.getX() == myHead.getX() + 1 && segment.getY() == myHead.getY()) { + isMoveSafe.remove("right"); + } + if (segment.getX() == myHead.getX() - 1 && segment.getY() == myHead.getY()) { + isMoveSafe.remove("left"); + } + if (segment.getX() == myHead.getX() && segment.getY() == myHead.getY() + 1) { + isMoveSafe.remove("up"); + } + if (segment.getX() == myHead.getX() && segment.getY() == myHead.getY() - 1) { + isMoveSafe.remove("down"); + } + } + } - // TODO: Passo 3 - impedir que a cobra bata nas adversarias + // TODO: Passo 3 — impedir que a cobra bata nas adversárias // List opponents = state.getBoard().getSnakes(); // Sobrou alguma direcao segura? diff --git a/src/main/java/com/mauadev/code/entities/Game.java b/src/main/java/com/mauadev/code/entities/Game.java index 789702d..25fcb38 100644 --- a/src/main/java/com/mauadev/code/entities/Game.java +++ b/src/main/java/com/mauadev/code/entities/Game.java @@ -1,4 +1,4 @@ -package com.mauadev.code.entities; +package com.mauadev.code.entities; /** Metadados da partida. */ public class Game { diff --git a/src/main/java/com/mauadev/code/entities/GameState.java b/src/main/java/com/mauadev/code/entities/GameState.java index 7f01fe2..27331f1 100644 --- a/src/main/java/com/mauadev/code/entities/GameState.java +++ b/src/main/java/com/mauadev/code/entities/GameState.java @@ -1,4 +1,4 @@ -package com.mauadev.code.entities; +package com.mauadev.code.entities; /** O pacote completo que chega em /start, /move e /end. */ public class GameState { diff --git a/src/test/java/com/mauadev/HandlerTest.java b/src/test/java/com/mauadev/HandlerTest.java index 81bfc04..f5748ec 100644 --- a/src/test/java/com/mauadev/HandlerTest.java +++ b/src/test/java/com/mauadev/HandlerTest.java @@ -1,4 +1,4 @@ -package com.mauadev; +package com.mauadev; import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; @@ -144,6 +144,53 @@ public void testMove_naoSobe() { } } + // --- T4: evita parede quando tem opcao --- + + @Test + @DisplayName("T4 - evita parede quando tem outra opcao") + public void testMove_evitaParede() { + // Cobra no canto (0,0), pescoco a direita (1,0): + // left (x=-1) e down (y=-1) saem do tabuleiro. Pescoço bloqueia right. + // A única opção segura é up. + String state = gameStateJson(0, 0, 1, 0); + for (int i = 0; i < 50; i++) { + APIGatewayProxyResponseEvent resp = handler.handleRequest(request("/move", state), context); + String move = parseBody(resp.getBody()).get("move"); + assertNotEquals("left", move, "saiu do tabuleiro para a esquerda"); + assertNotEquals("down", move, "saiu do tabuleiro para baixo"); + assertNotEquals("right", move, "voltou pelo pescoco"); + assertEquals("up", move, "deveria ter escolhido up"); + } + } + + // --- T5: evita proprio corpo quando tem opcao --- + + @Test + @DisplayName("T5 - evita proprio corpo quando tem opcao") + public void testMove_evitaProprioCorpo() { + // Cabeca em (5,4), pescoco em (4,4) [esquerda], corpo em (5,5) [acima] + // Restam right e down + String stateJson = """ + { + "game": {"id": "teste", "timeout": 500}, + "turn": 1, + "board": {"height": 11, "width": 11, "food": [], "hazards": [], "snakes": []}, + "you": { + "id": "s1", "name": "cobra", "health": 100, + "body": [{"x": 5, "y": 4}, {"x": 4, "y": 4}, {"x": 5, "y": 5}, {"x": 4, "y": 3}], + "head": {"x": 5, "y": 4}, "length": 4 + } + } + """; + for (int i = 0; i < 50; i++) { + APIGatewayProxyResponseEvent resp = handler.handleRequest(request("/move", stateJson), context); + String move = parseBody(resp.getBody()).get("move"); + assertNotEquals("left", move, "voltou pelo pescoco"); + assertNotEquals("up", move, "bateu no proprio corpo"); + assertTrue(List.of("right", "down").contains(move)); + } + } + // --- T6: comportamento sem safe moves (nao deve lancar excecao) --- @Test diff --git a/src/test/java/com/mauadev/TestContext.java b/src/test/java/com/mauadev/TestContext.java index 5c3d196..8c08353 100644 --- a/src/test/java/com/mauadev/TestContext.java +++ b/src/test/java/com/mauadev/TestContext.java @@ -1,4 +1,4 @@ -package com.mauadev; +package com.mauadev; import com.amazonaws.services.lambda.runtime.ClientContext; import com.amazonaws.services.lambda.runtime.CognitoIdentity; From b9a7441179b2b7b42d1e4b8a398951f7901cff53 Mon Sep 17 00:00:00 2001 From: Leonardo Moreno Date: Thu, 10 Sep 2026 19:25:44 -0300 Subject: [PATCH 3/5] fix: minor fixes again --- src/main/java/com/mauadev/code/Logic.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/mauadev/code/Logic.java b/src/main/java/com/mauadev/code/Logic.java index d432e13..f5c4001 100644 --- a/src/main/java/com/mauadev/code/Logic.java +++ b/src/main/java/com/mauadev/code/Logic.java @@ -97,6 +97,8 @@ public static String getMove(GameState state) { // pescoco acima da cabeca -> nao suba isMoveSafe.remove("up"); } + } + // 2. Impedir que a cobra saia do tabuleiro (paredes) if (state.getBoard() != null) { int boardWidth = state.getBoard().getWidth(); From f2740d5abafb5dd16e274b7012385d050ab143c3 Mon Sep 17 00:00:00 2001 From: Leonardo Moreno Date: Thu, 10 Sep 2026 19:37:46 -0300 Subject: [PATCH 4/5] fix: correct Java template tests and logic --- src/test/java/com/mauadev/TestContext.java | 76 ++++++++++++++++++---- 1 file changed, 64 insertions(+), 12 deletions(-) diff --git a/src/test/java/com/mauadev/TestContext.java b/src/test/java/com/mauadev/TestContext.java index 8c08353..a40973f 100644 --- a/src/test/java/com/mauadev/TestContext.java +++ b/src/test/java/com/mauadev/TestContext.java @@ -6,17 +6,69 @@ import com.amazonaws.services.lambda.runtime.LambdaLogger; public class TestContext implements Context { - @Override public String getAwsRequestId() { return "test-request-id"; } - @Override public String getLogGroupName() { return "/aws/lambda/test"; } - @Override public String getLogStreamName() { return "test-stream"; } - @Override public String getFunctionName() { return "test-function"; } - @Override public String getFunctionVersion() { return "1"; } - @Override public String getInvokedFunctionArn() { return "arn:test"; } - @Override public CognitoIdentity getIdentity() { return null; } - @Override public ClientContext getClientContext() { return null; } - @Override public int getRemainingTimeInMillis() { return 5000; } - @Override public int getMemoryLimitInMB() { return 512; } - @Override public LambdaLogger getLogger() { - return message -> System.out.println("[LAMBDA] " + message); + + @Override + public String getAwsRequestId() { + return "test-request-id"; + } + + @Override + public String getLogGroupName() { + return "test-log-group"; + } + + @Override + public String getLogStreamName() { + return "test-log-stream"; + } + + @Override + public String getFunctionName() { + return "battlesnake-test"; + } + + @Override + public String getFunctionVersion() { + return "1"; + } + + @Override + public String getInvokedFunctionArn() { + return "arn:aws:lambda:test"; + } + + @Override + public CognitoIdentity getIdentity() { + return null; + } + + @Override + public ClientContext getClientContext() { + return null; + } + + @Override + public int getRemainingTimeInMillis() { + return 30000; + } + + @Override + public int getMemoryLimitInMB() { + return 512; + } + + @Override + public LambdaLogger getLogger() { + return new LambdaLogger() { + @Override + public void log(String message) { + System.out.print(message); + } + + @Override + public void log(byte[] message) { + System.out.print(new String(message)); + } + }; } } \ No newline at end of file From 055b696bfb3f9441079c75d057dd352e2ca302bd Mon Sep 17 00:00:00 2001 From: Leonardo Moreno Date: Tue, 15 Sep 2026 21:07:54 -0300 Subject: [PATCH 5/5] fix: some fixes --- .github/workflows/ci.yml | 23 ++ src/main/java/com/mauadev/code/Handler.java | 302 ++++++++++++++---- src/main/java/com/mauadev/code/Logic.java | 10 +- .../java/com/mauadev/code/entities/Snake.java | 1 - src/test/java/com/mauadev/HandlerTest.java | 152 ++++++++- src/test/java/com/mauadev/TestContext.java | 4 +- terraform/app/main.tf | 29 +- 7 files changed, 451 insertions(+), 70 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..12ef52a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + cache: 'maven' + + - name: Run Tests + run: mvn test diff --git a/src/main/java/com/mauadev/code/Handler.java b/src/main/java/com/mauadev/code/Handler.java index 8485d4c..7a72ec4 100644 --- a/src/main/java/com/mauadev/code/Handler.java +++ b/src/main/java/com/mauadev/code/Handler.java @@ -2,7 +2,7 @@ // Ponte entre o AWS Lambda e a logica da sua cobra. // -// Voce NAO precisa mexer aqui. Este arquivo recebe o evento do API Gateway, +// Voce NAO precisa mexer aqui. Este arquivo recebe o evento da Lambda, // descobre qual rota do Battlesnake foi chamada e repassa para Logic.java. // // Rotas da API (https://docs.battlesnake.com/api): @@ -10,90 +10,280 @@ // POST /start -> a partida comecou // POST /move -> escolha a jogada deste turno // POST /end -> a partida acabou +// +// O evento chega como Map porque a Lambda tem dois formatos de payload e o +// template aceita os dois: +// - 1.0, do API Gateway REST: o caminho vem em "path" +// - 2.0, da Function URL e do HTTP API: vem em "rawPath" e em +// "requestContext.http.path" +// Tipar a entrada como APIGatewayProxyRequestEvent aceitaria so o 1.0, e todo +// evento 2.0 chegaria com os campos nulos. import com.amazonaws.services.lambda.runtime.Context; import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; import com.mauadev.code.entities.GameState; -import java.util.Collections; +import java.util.HashMap; import java.util.Map; -public class Handler implements RequestHandler { +public class Handler implements RequestHandler, APIGatewayProxyResponseEvent> { private static final Gson GSON = new Gson(); - @Override - public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { - APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent(); - String path = request.getPath(); - Object responseBody = null; + /** Rotas do Battlesnake, na forma canonica. */ + private static final String[] ROTAS = {"/start", "/move", "/end"}; + + /** Nomes de stage que o API Gateway pode colocar na frente do caminho. */ + private static final String[] STAGES = {"dev", "homolog", "prod", "staging"}; + + /** Corpo de requisicao que nao da para usar: vira 400, e nao 500. */ + private static class CorpoInvalidoException extends RuntimeException { + CorpoInvalidoException(String mensagem) { + super(mensagem); + } + } + @Override + public APIGatewayProxyResponseEvent handleRequest(Map request, Context context) { try { - switch (path) { - case "/": - responseBody = Logic.info(); - break; - case "/start": - handleStart(request, context); - break; - case "/move": - responseBody = handleMove(request, context); - break; - case "/end": - handleEnd(request, context); - break; - default: - return response - .withStatusCode(404) - .withBody(GSON.toJson(Map.of("error", "Path not found: " + path))); + String rota = extrairRota(request); + + if (rota == null) { + return resposta(404, erro("rota nao encontrada: " + caminhoBruto(request))); } - response.setStatusCode(200); - response.setHeaders(Collections.singletonMap("Content-Type", "application/json")); - if (responseBody != null) { - response.setBody(GSON.toJson(responseBody)); + if (!metodoValido(request, rota)) { + return resposta(404, erro("metodo nao permitido para a rota: " + caminhoBruto(request))); } + switch (rota) { + case "/start": { + GameState state = lerEstado(request); + log(context, "JOGO COMECOU (partida " + state.getGame().getId() + ")"); + Logic.start(state); + return resposta(200, "ok"); + } + case "/move": { + GameState state = lerEstado(request); + String jogada = Logic.getMove(state); + log(context, "MOVE " + state.getTurn() + ": " + jogada); + return resposta(200, Map.of("move", jogada)); + } + case "/end": { + GameState state = lerEstado(request); + log(context, "FIM DE JOGO apos " + state.getTurn() + " turnos"); + Logic.end(state); + return resposta(200, "ok"); + } + default: + return resposta(200, Logic.info()); + } + + } catch (CorpoInvalidoException e) { + log(context, "REQUISICAO INVALIDA: " + e.getMessage()); + return resposta(400, erro(e.getMessage())); + } catch (Exception e) { - context.getLogger().log("ERROR: " + e.getMessage()); - response.setStatusCode(500); - response.setBody(GSON.toJson(Map.of("error", e.getMessage()))); + log(context, "ERROR: " + e); + return resposta(500, erro(e.getMessage())); + } + } + + // ------------------------------------------------------------------ + // Roteamento + // ------------------------------------------------------------------ + + /** + * Descobre qual rota do Battlesnake foi chamada, nos dois formatos de evento. + * + *

Compara pelo sufixo do caminho, entao tolera o nome do stage na frente + * ("/dev/move") e barra sobrando no fim ("/move/"). + * + * @return "/", "/start", "/move", "/end", ou {@code null} se a rota for desconhecida + */ + static String extrairRota(Map request) { + String caminho = caminhoBruto(request); + + if (caminho == null) { + return null; } - return response; + String limpo = caminho.trim(); + while (limpo.length() > 1 && limpo.endsWith("/")) { + limpo = limpo.substring(0, limpo.length() - 1); + } + + String normalizado = limpo; + for (String stage : STAGES) { + if (normalizado.equals("/" + stage)) { + normalizado = "/"; + break; + } else if (normalizado.startsWith("/" + stage + "/")) { + normalizado = normalizado.substring(stage.length() + 1); + break; + } + } + + if (normalizado.isEmpty() || normalizado.equals("/")) { + return "/"; + } + + for (String rota : ROTAS) { + if (normalizado.equals(rota)) { + return rota; + } + } + + return null; } - /** POST /start */ - private void handleStart(APIGatewayProxyRequestEvent request, Context context) { - GameState state = parseState(request); - context.getLogger().log("JOGO COMECOU (partida " + (state != null && state.getGame() != null ? state.getGame().getId() : "?") + ")"); - if (state != null) Logic.start(state); + static String extrairMetodo(Map request) { + if (request == null) { + return null; + } + + // formato 1.0 (API Gateway REST) + String metodo = texto(request.get("httpMethod")); + if (metodo != null) { + return metodo; + } + + // formato 2.0 (Function URL e HTTP API) + Object contexto = request.get("requestContext"); + if (contexto instanceof Map) { + @SuppressWarnings("unchecked") + Map rc = (Map) contexto; + Object http = rc.get("http"); + if (http instanceof Map) { + @SuppressWarnings("unchecked") + Map httpMap = (Map) http; + metodo = texto(httpMap.get("method")); + if (metodo != null) { + return metodo; + } + } + return texto(rc.get("httpMethod")); + } + + return null; } - /** POST /move */ - private Map handleMove(APIGatewayProxyRequestEvent request, Context context) { - GameState state = parseState(request); - String chosen = (state != null) ? Logic.getMove(state) : "up"; - context.getLogger().log("MOVE " + (state != null ? state.getTurn() : "?") + ": " + chosen); - return Map.of("move", chosen); + static boolean metodoValido(Map request, String rota) { + String metodo = extrairMetodo(request); + if (metodo == null) { + return true; + } + + if ("/".equals(rota)) { + String httpMethod = texto(request.get("httpMethod")); + if (httpMethod != null) { + return httpMethod.equalsIgnoreCase("GET"); + } + return metodo.equalsIgnoreCase("GET") || metodo.equalsIgnoreCase("POST"); + } + + return metodo.equalsIgnoreCase("POST"); } - /** POST /end */ - private void handleEnd(APIGatewayProxyRequestEvent request, Context context) { - GameState state = parseState(request); - context.getLogger().log("FIM DE JOGO apos " + (state != null ? state.getTurn() : "?") + " turnos"); - if (state != null) Logic.end(state); + /** Le o caminho do evento, seja ele no formato 1.0 ou 2.0. */ + @SuppressWarnings("unchecked") + private static String caminhoBruto(Map request) { + if (request == null) { + return null; + } + + // formato 1.0 (API Gateway REST) + String caminho = texto(request.get("path")); + if (caminho != null) { + return caminho; + } + + // formato 2.0 (Function URL e HTTP API) + caminho = texto(request.get("rawPath")); + if (caminho != null) { + return caminho; + } + + Object contexto = request.get("requestContext"); + if (contexto instanceof Map) { + Map rc = (Map) contexto; + Object http = rc.get("http"); + if (http instanceof Map) { + caminho = texto(((Map) http).get("path")); + if (caminho != null) { + return caminho; + } + } + return texto(rc.get("path")); + } + + return null; } - /** Desserializa o corpo da requisicao como GameState. Retorna null se falhar. */ - private GameState parseState(APIGatewayProxyRequestEvent request) { + // ------------------------------------------------------------------ + // Corpo da requisicao + // ------------------------------------------------------------------ + + /** Desserializa o corpo como GameState. Corpo ruim vira 400, e nao 500. */ + private static GameState lerEstado(Map request) { + String corpo = texto(request.get("body")); + + if (corpo == null || corpo.isBlank()) { + throw new CorpoInvalidoException("corpo da requisicao vazio"); + } + + GameState state; try { - return GSON.fromJson(request.getBody(), GameState.class); - } catch (Exception e) { - return null; + state = GSON.fromJson(corpo, GameState.class); + } catch (JsonSyntaxException e) { + throw new CorpoInvalidoException("JSON invalido: " + e.getMessage()); + } + + if (state == null || state.getYou() == null || state.getYou().getHead() == null) { + throw new CorpoInvalidoException("estado de jogo incompleto: falta 'you'"); + } + if (state.getGame() == null) { + throw new CorpoInvalidoException("estado de jogo incompleto: falta 'game'"); + } + + return state; + } + + // ------------------------------------------------------------------ + // Resposta + // ------------------------------------------------------------------ + + /** Monta a resposta com Content-Type, inclusive nos erros. */ + private static APIGatewayProxyResponseEvent resposta(int status, Object corpo) { + Map headers = new HashMap<>(); + headers.put("Content-Type", corpo instanceof String ? "text/plain" : "application/json"); + + return new APIGatewayProxyResponseEvent() + .withStatusCode(status) + .withHeaders(headers) + .withBody(corpo instanceof String ? (String) corpo : GSON.toJson(corpo)); + } + + /** + * HashMap, e nao Map.of: se a excecao vier sem mensagem, Map.of lancaria + * NullPointerException dentro do proprio tratador de erro. + */ + private static Map erro(String mensagem) { + Map corpo = new HashMap<>(); + corpo.put("error", mensagem == null ? "erro interno" : mensagem); + return corpo; + } + + private static String texto(Object valor) { + return valor instanceof String ? (String) valor : null; + } + + private static void log(Context context, String mensagem) { + if (context != null && context.getLogger() != null) { + context.getLogger().log(mensagem); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/mauadev/code/Logic.java b/src/main/java/com/mauadev/code/Logic.java index f5c4001..c1aa616 100644 --- a/src/main/java/com/mauadev/code/Logic.java +++ b/src/main/java/com/mauadev/code/Logic.java @@ -20,7 +20,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; /** * Logica da cobra. E AQUI que voce programa a inteligencia da snake. @@ -32,7 +32,9 @@ */ public class Logic { - private static final Random RANDOM = new Random(); + // ThreadLocalRandom, e nao um Random estatico: com o SnapStart ligado o + // seed de um campo estatico seria capturado no snapshot, e toda execucao + // restaurada sortearia exatamente a mesma sequencia de jogadas. /** * GET / - chamado quando voce cadastra a cobra e a cada partida. @@ -144,11 +146,11 @@ public static String getMove(GameState state) { // Emergencia: todas as direcoes sao perigosas. // Escolhemos uma ao acaso entre as 4 - melhor do que travar. List allMoves = Arrays.asList("up", "down", "left", "right"); - return allMoves.get(RANDOM.nextInt(allMoves.size())); + return allMoves.get(ThreadLocalRandom.current().nextInt(allMoves.size())); } // Escolhe uma direcao segura ao acaso. - String chosen = isMoveSafe.get(RANDOM.nextInt(isMoveSafe.size())); + String chosen = isMoveSafe.get(ThreadLocalRandom.current().nextInt(isMoveSafe.size())); // TODO: Passo 4 - ir atras da comida em vez de sortear, para nao morrer de fome // List food = state.getBoard().getFood(); diff --git a/src/main/java/com/mauadev/code/entities/Snake.java b/src/main/java/com/mauadev/code/entities/Snake.java index 46b24e6..4a6172c 100644 --- a/src/main/java/com/mauadev/code/entities/Snake.java +++ b/src/main/java/com/mauadev/code/entities/Snake.java @@ -71,7 +71,6 @@ public void setHead(Coordinate head) { } public void setLength(int length) { this.length = length; - this.body = this.body.subList(0, length); } public void setShout(String shout) { this.shout = shout; diff --git a/src/test/java/com/mauadev/HandlerTest.java b/src/test/java/com/mauadev/HandlerTest.java index f5748ec..4813034 100644 --- a/src/test/java/com/mauadev/HandlerTest.java +++ b/src/test/java/com/mauadev/HandlerTest.java @@ -1,7 +1,6 @@ package com.mauadev; import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; @@ -11,6 +10,7 @@ import org.junit.jupiter.api.Test; import java.lang.reflect.Type; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -58,10 +58,28 @@ private String gameStateJson(int hx, int hy, int nx, int ny) { """, hx, hy, nx, ny, nx, ny - 1, hx, hy); } - private APIGatewayProxyRequestEvent request(String path, String body) { - APIGatewayProxyRequestEvent req = new APIGatewayProxyRequestEvent(); - req.setPath(path); - req.setBody(body); + /** Evento no formato 1.0 (API Gateway REST): o caminho vem em "path". */ + private Map request(String path, String body) { + Map req = new HashMap<>(); + req.put("path", path); + req.put("body", body); + return req; + } + + /** Evento no formato 2.0 (Function URL): o caminho vem em "rawPath". */ + private Map requestV2(String path, String body) { + Map http = new HashMap<>(); + http.put("path", path); + http.put("method", "POST"); + + Map requestContext = new HashMap<>(); + requestContext.put("http", http); + + Map req = new HashMap<>(); + req.put("version", "2.0"); + req.put("rawPath", path); + req.put("requestContext", requestContext); + req.put("body", body); return req; } @@ -232,4 +250,128 @@ public void testEnd_retorna200() { APIGatewayProxyResponseEvent response = handler.handleRequest(request("/end", state), context); assertEquals(200, response.getStatusCode()); } + + // --- T7: formato 2.0 (Function URL e HTTP API) --- + + @Test + @DisplayName("T7a - GET / no formato 2.0 retorna as informacoes da cobra") + public void testFormatoV2_info() { + APIGatewayProxyResponseEvent response = handler.handleRequest(requestV2("/", null), context); + + assertEquals(200, response.getStatusCode()); + assertEquals("1", parseBody(response.getBody()).get("apiversion")); + } + + @Test + @DisplayName("T7b - POST /move no formato 2.0 retorna uma direcao valida") + public void testFormatoV2_move() { + String state = gameStateJson(5, 4, 4, 4); + APIGatewayProxyResponseEvent response = handler.handleRequest(requestV2("/move", state), context); + + assertEquals(200, response.getStatusCode()); + String jogada = parseBody(response.getBody()).get("move"); + assertTrue(DIRECOES.contains(jogada), "Direcao invalida: " + jogada); + } + + @Test + @DisplayName("T7c - formato 2.0 sem rawPath ainda resolve por requestContext.http.path") + public void testFormatoV2_semRawPath() { + String state = gameStateJson(5, 4, 4, 4); + Map req = requestV2("/move", state); + req.remove("rawPath"); + + APIGatewayProxyResponseEvent response = handler.handleRequest(req, context); + + assertEquals(200, response.getStatusCode()); + assertTrue(DIRECOES.contains(parseBody(response.getBody()).get("move"))); + } + + // --- T8: prefixo de stage e barra sobrando --- + + @Test + @DisplayName("T8 - caminho com o stage na frente e barra no fim ainda roteia") + public void testPrefixoDeStage() { + String state = gameStateJson(5, 4, 4, 4); + + assertEquals(200, handler.handleRequest(request("/dev/move", state), context).getStatusCode()); + assertEquals(200, handler.handleRequest(request("/move/", state), context).getStatusCode()); + assertEquals(200, handler.handleRequest(request("/dev", null), context).getStatusCode()); + } + + @Test + @DisplayName("T8b - rota realmente desconhecida continua sendo 404") + public void testRotaDesconhecida_retorna404() { + APIGatewayProxyResponseEvent response = handler.handleRequest(request("/nao-existe", null), context); + + assertEquals(404, response.getStatusCode()); + assertEquals("application/json", response.getHeaders().get("Content-Type")); + } + + @Test + @DisplayName("T8c - prefixo nao reconhecido como stage deve retornar 404") + public void testRoteamentoExato_urlPermissivaRetorna404() { + String state = gameStateJson(5, 4, 4, 4); + APIGatewayProxyResponseEvent response = handler.handleRequest(request("/alguma-coisa/move", state), context); + + assertEquals(404, response.getStatusCode()); + } + + @Test + @DisplayName("T8d - metodo incorreto retorna 404") + public void testMetodoIncorreto_retorna404() { + Map reqGetMove = request("/move", null); + reqGetMove.put("httpMethod", "GET"); + APIGatewayProxyResponseEvent resp1 = handler.handleRequest(reqGetMove, context); + assertEquals(404, resp1.getStatusCode()); + + Map reqPostInfo = request("/", null); + reqPostInfo.put("httpMethod", "POST"); + APIGatewayProxyResponseEvent resp2 = handler.handleRequest(reqPostInfo, context); + assertEquals(404, resp2.getStatusCode()); + } + + // --- T9: corpo invalido vira 400, e nao 200 nem 500 --- + + @Test + @DisplayName("T9a - POST /move sem corpo retorna 400") + public void testMove_semCorpo_retorna400() { + APIGatewayProxyResponseEvent response = handler.handleRequest(request("/move", null), context); + + assertEquals(400, response.getStatusCode()); + assertNotNull(parseBody(response.getBody()).get("error")); + } + + @Test + @DisplayName("T9b - POST /move com JSON quebrado retorna 400") + public void testMove_jsonQuebrado_retorna400() { + APIGatewayProxyResponseEvent response = handler.handleRequest(request("/move", "{isso nao e json}"), context); + + assertEquals(400, response.getStatusCode()); + } + + @Test + @DisplayName("T9c - POST /move sem o campo 'you' retorna 400, e nao uma jogada inventada") + public void testMove_semYou_retorna400() { + String semYou = """ + {"game": {"id": "x", "timeout": 500}, "turn": 1, + "board": {"height": 11, "width": 11, "food": [], "hazards": [], "snakes": []}} + """; + APIGatewayProxyResponseEvent response = handler.handleRequest(request("/move", semYou), context); + + assertEquals(400, response.getStatusCode()); + assertNull(parseBody(response.getBody()).get("move"), "nao pode devolver jogada para estado invalido"); + } + + // --- T10: toda resposta traz Content-Type --- + + @Test + @DisplayName("T10 - todas as respostas trazem Content-Type") + public void testContentTypeSemprePresente() { + String state = gameStateJson(5, 4, 4, 4); + + assertNotNull(handler.handleRequest(request("/", null), context).getHeaders().get("Content-Type")); + assertNotNull(handler.handleRequest(request("/move", state), context).getHeaders().get("Content-Type")); + assertNotNull(handler.handleRequest(request("/move", null), context).getHeaders().get("Content-Type")); + assertNotNull(handler.handleRequest(request("/nao-existe", null), context).getHeaders().get("Content-Type")); + } } \ No newline at end of file diff --git a/src/test/java/com/mauadev/TestContext.java b/src/test/java/com/mauadev/TestContext.java index a40973f..070e080 100644 --- a/src/test/java/com/mauadev/TestContext.java +++ b/src/test/java/com/mauadev/TestContext.java @@ -62,12 +62,12 @@ public LambdaLogger getLogger() { return new LambdaLogger() { @Override public void log(String message) { - System.out.print(message); + System.out.println(message); } @Override public void log(byte[] message) { - System.out.print(new String(message)); + System.out.println(new String(message)); } }; } diff --git a/terraform/app/main.tf b/terraform/app/main.tf index fcfd37c..1ab3c2e 100644 --- a/terraform/app/main.tf +++ b/terraform/app/main.tf @@ -55,9 +55,33 @@ resource "aws_lambda_function" "lambda_battle_snake_java" { handler = "com.mauadev.code.Handler::handleRequest" runtime = "java17" + # A JVM sobe devagar e a CPU da Lambda escala com a memoria: em 128 MB (o + # padrao quando nada e definido) o cold start passa de varios segundos e + # estoura o timeout padrao de 3s. + memory_size = 1024 + timeout = 10 + + # SnapStart tira um snapshot da JVM ja inicializada e restaura a partir dele, + # cortando o cold start de segundos para centenas de milissegundos. So vale + # para versoes publicadas, por isso o publish = true. + publish = true + + snap_start { + apply_on = "PublishedVersions" + } + source_code_hash = filebase64sha256("../../target/battlesnake-lambda-1.0.jar") } +# O snapshot pertence a uma versao publicada. Sem apontar o API Gateway para um +# alias, o trafego iria para $LATEST, que roda sem SnapStart e anula o ganho. +resource "aws_lambda_alias" "live" { + name = "live" + description = "Versao publicada que o API Gateway invoca, com SnapStart ativo" + function_name = aws_lambda_function.lambda_battle_snake_java.function_name + function_version = aws_lambda_function.lambda_battle_snake_java.version +} + # ---- configurando o api gateway ---- @@ -96,7 +120,7 @@ resource "aws_api_gateway_integration" "integration" { http_method = aws_api_gateway_method.method.http_method integration_http_method = "POST" type = "AWS_PROXY" - uri = aws_lambda_function.lambda_battle_snake_java.invoke_arn + uri = aws_lambda_alias.live.invoke_arn } resource "aws_api_gateway_integration" "root_integration" { @@ -105,7 +129,7 @@ resource "aws_api_gateway_integration" "root_integration" { http_method = aws_api_gateway_method.root_method.http_method integration_http_method = "POST" type = "AWS_PROXY" - uri = aws_lambda_function.lambda_battle_snake_java.invoke_arn + uri = aws_lambda_alias.live.invoke_arn } # adicionando permiossoes na lambda para o apigateway poder executala @@ -113,6 +137,7 @@ resource "aws_lambda_permission" "api_gateway_permission" { statement_id = "AllowAPIGatewayToInvoke" action = "lambda:InvokeFunction" function_name = aws_lambda_function.lambda_battle_snake_java.function_name + qualifier = aws_lambda_alias.live.name principal = "apigateway.amazonaws.com" source_arn = "${aws_api_gateway_rest_api.api.execution_arn}/*/*" }