diff --git a/app/build.gradle b/app/build.gradle index 3232fb0f..1f23be05 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -45,4 +45,5 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.0-M1' testImplementation 'org.mockito:mockito-core:5.3.1' testImplementation 'org.mockito:mockito-junit-jupiter:5.3.1' + testImplementation 'org.hamcrest:hamcrest:2.2' } diff --git a/app/src/main/java/me/chester/minitruco/android/SalaActivity.java b/app/src/main/java/me/chester/minitruco/android/SalaActivity.java index 4e78a0df..9d69a9fa 100644 --- a/app/src/main/java/me/chester/minitruco/android/SalaActivity.java +++ b/app/src/main/java/me/chester/minitruco/android/SalaActivity.java @@ -121,7 +121,7 @@ protected void exibeMesaForaDoJogo(String notificacaoI) { // parte inferior da tela (textViewJogador1), sucedido por // "(você)"; sucede a pessoa que é gerente com "(gerente)" int p = (posJogador - 1) % 4; - textViewJogador1.setText(nomes[p] + (p == 0 ? " (você/gerente)" : "(você)")); + textViewJogador1.setText(nomes[p] + (p == 0 ? " (você/gerente)" : " (você)")); p = (p + 1) % 4; textViewJogador2.setText(nomes[p] + (p == 0 ? " (gerente)" : "")); p = (p + 1) % 4; diff --git a/core/build.gradle b/core/build.gradle index 57f28aa5..b794e59d 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -12,6 +12,7 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.0-M1' testImplementation 'org.mockito:mockito-core:5.3.1' testImplementation 'org.mockito:mockito-junit-jupiter:5.3.1' + testImplementation 'org.hamcrest:hamcrest:2.2' } test { diff --git a/core/src/main/java/me/chester/minitruco/core/Modo.java b/core/src/main/java/me/chester/minitruco/core/Modo.java index 81aa5650..f8ba2249 100644 --- a/core/src/main/java/me/chester/minitruco/core/Modo.java +++ b/core/src/main/java/me/chester/minitruco/core/Modo.java @@ -10,6 +10,12 @@ */ public interface Modo { + /** + * @return instância de Modo correspondente ao modoStr + * @param modoStr String de 1 caractere indicando o modo desejado. Ex.: + * "M" para mineiro, "P" para paulista, etc. + * @throws IllegalArgumentException se o modo for inválido + */ static Modo fromString(String modoStr) { switch (modoStr) { case "M": @@ -25,6 +31,15 @@ static Modo fromString(String modoStr) { } } + static boolean isModoValido(String modoStr) { + try { + fromString(modoStr); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + int pontuacaoParaMaoDeX(); int valorInicialDaMao(); diff --git a/core/src/test/java/me/chester/minitruco/core/JogadorTest.java b/core/src/test/java/me/chester/minitruco/core/JogadorTest.java index 22125839..1b25948e 100644 --- a/core/src/test/java/me/chester/minitruco/core/JogadorTest.java +++ b/core/src/test/java/me/chester/minitruco/core/JogadorTest.java @@ -1,7 +1,8 @@ package me.chester.minitruco.core; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.matchesPattern; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -38,7 +39,7 @@ private static String sanitizaNome(String nome) { void assertNomeDefault(String nome) { String regex = "^sem_nome_\\d{1,3}$"; - assertTrue(nome.matches(regex), nome + " não deu match em " + regex); + assertThat(nome, matchesPattern(regex)); } @Test diff --git a/docs/documentacao-para-desenvolvimento.md b/docs/documentacao-para-desenvolvimento.md index 361e0b44..374801af 100644 --- a/docs/documentacao-para-desenvolvimento.md +++ b/docs/documentacao-para-desenvolvimento.md @@ -281,7 +281,11 @@ Quando o miniTruco foi criado (2005), poucas pessoas possuíam celulares e plano Idealmente isso seria feito serializando as chamadas e objetos com um protocolo binário (se fosse hoje em dia, algo como [Protobuf](https://protobuf.dev/)). Mas eu também queria que fosse possível interagir diretamente com o servidor via terminal (para testes, depuração e também por diversão), então acabei criando uma "linguagem" que define comandos e notificações em texto simples. -O protocolo consiste em _comandos_ enviados pelo cliente (ex.: `J 3c` para "`J`ogar o `3` de `c`opas") e _notificações_ enviadas pelo servidor (ex.: `V 2 F` para "`v`ez do jogador na posição `2`, que não pode (`F`alse) jogar fechada). Os clientes devem processar as notificações assincronamente, e podem enviar comandos a qualquer momento, desde que faça sentido (ex.: o comando `J` só funciona se um jogo estiver em andamento e for a vez do jogador). +O protocolo consiste em _comandos_ enviados pelo cliente (ex.: `J 3c` para "`J`ogar o `3` de `c`opas") e _notificações_ enviadas pelo servidor (ex.: `V 2 F` para "`v`ez do jogador na posição `2`, que não pode (`F`alse) jogar fechada). + +Os clientes devem processar as notificações assincronamente, e podem enviar comandos a qualquer momento, desde que faça sentido (ex.: o comando `J` só funciona se um jogo estiver em andamento e for a vez do jogador). + +Comandos com erros de sintaxe ou argumentos inválidos são ignorados. ### Testando (jogando) via nc/telnet @@ -310,7 +314,8 @@ TODO colocar um exemplo de jogo aqui (GIF ou whatnot) - ``: Quatro sequências de caracteres, separadas por `|`. Exemplo: `john|bot|ringo|george`. - ``: `P` para truco paulista, `M` para truco mineiro, `V` para manilha velha ou `L` baralho limpo. - ``: Posição de um jogador na sala/partida, de 1 a 4. É constante durante a partida, mas pode mudar fora dela (o servidor manda uma notificação `I` sempre que a formação da sala mudar). -- `` : Informa o tipo de sala em que estamos conectados. Pode ser `BLT` (bluetooth), `PUB` (pública) ou `PRI-nnnnn` (privada, com o código nnnnn). +- `` : Informa o tipo de sala em que estamos conectados. Pode ser `BLT` (bluetooth), `PUB` (pública) ou `PRI-código` (privada, com o código especificado). +- `` : String de números e letras maiúsculas que identifica uma sala privada. Exemplo: `A9327J`. - ``: Uma das duas equipes (duplas). Pode ser 1 (equpe dos jogadores 1 e 3) ou 2 (jogadores 2 e 4). - ``: número aleatório grande que permite que todos os clientes mostrem a mesma frase (o "balãozinho") para um evento. Por exemplo, se o jogador 1 pediu truco (paulista) e o número sorteado foi 12345678, todos irão receber `T 1 3 12345678`; se o cliente tem 8 frases possíveis para truco, ele calcula 12345678 % 8 = 6 e exibe a frase de índice 6. Dessa forma, todos os clientes mostram a mesma frase (se estiverem com a mesma versão do [strings.xml](../app/src/main/res/values/strings.xml)) e o servidor não tem que saber quantas frases tem cada tipo de mensagem. @@ -321,6 +326,9 @@ TODO colocar um exemplo de jogo aqui (GIF ou whatnot) - `B `: Informa o número do build do cliente, para que o servidor verifique compatibilidade - `N `: Define o nome do jogador (é sanitizado; se for 100% inválido recebe um default) - `E PUB `: Entra em uma sala pública (criando, se não tiver nenhuma com vaga) com o modo especificado +- `E NPU `: Cria uma nova sala pública e entra nela +- `E PRI `: Cria uma nova sala privada e entra nela +- `E PRI-`: Entra em uma sala privada com o código #### Dentro da sala (fora de jogo) - `S`: Sai da sala (encerrando a partida, se houver uma em andamento) @@ -347,10 +355,6 @@ TODO colocar um exemplo de jogo aqui (GIF ou whatnot) - `W x.y`: `Versão do servidor (outras infos podem vir no futuro) - `X CI`: `Comando inválido -- `X AI`: `Argumento inválido -- `X FS`: `Você não está numa sala -- `X NO`: `É preciso atribuir um nome para entrar na sala -- `X JE sala`: Você já está na sala de código `sala` - `N nome`: Seu nome foi definido como `nome` - `I `: Informações da sala (vide detalhes em "convenções") - `P `: Início da partida diff --git a/server/build.gradle b/server/build.gradle index 953a6e5f..e4ac3596 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -11,6 +11,7 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.0-M1' testImplementation 'org.mockito:mockito-core:5.3.1' testImplementation 'org.mockito:mockito-junit-jupiter:5.3.1' + testImplementation 'org.hamcrest:hamcrest:2.2' } java { sourceCompatibility = JavaVersion.VERSION_19 diff --git a/server/src/main/java/me/chester/minitruco/server/ComandoE.java b/server/src/main/java/me/chester/minitruco/server/ComandoE.java index a7d24207..a9bbd8eb 100644 --- a/server/src/main/java/me/chester/minitruco/server/ComandoE.java +++ b/server/src/main/java/me/chester/minitruco/server/ComandoE.java @@ -3,39 +3,54 @@ /* SPDX-License-Identifier: BSD-3-Clause */ /* Copyright © 2005-2023 Carlos Duarte do Nascimento "Chester" */ +import me.chester.minitruco.core.Modo; + /** - * Entra numa sala. - *

- * Parâmetros: - * - "R ___" para entrar em uma sala (ou criar uma) com as regras especificadas - * (cada _ é T ou F para baralho limpo, manilha velha e modo mineiro) - *

- * Obs.: Se o jogador não tiver nome, um nome implícito será dado + * Entra numa sala. Sintaxe: + *

    + *
  • PUB modo - procura uma sala pública compatível, se não achar, cria
  • + *
  • NPU modo - cria nova sala pública
  • + *
  • PRI modo - cria nova sala privada
  • + *
  • PRI-nnnnn - entra numa sala privada existente
  • + *
+ * modos: "P" para truco paulista, "M" para mineiro, etc; vide classe `Modo` */ public class ComandoE extends Comando { @Override public void executa(String[] args, JogadorConectado j) { - - try { - if (j.getNome().equals("unnamed")) { - j.println("X NO"); + String subcomando, modo = null; + if (args.length < 2 || args.length > 3) { + return; + } + subcomando = args[1]; + if (args.length == 3) { + modo = args[2]; + if (!Modo.isModoValido(modo)) { return; } - if (j.getSala() != null) { - // TODO: mostrar o código se for sala pública? - j.println("X JE " + j.getSala().codigo); + } + if (j.getSala() != null) { + (new ComandoS()).executa(new String[]{"S"}, j); + } + try { + Sala s; + if ("PUB".equals(subcomando)) { + s = Sala.colocaEmSalaPublica(j, modo); + } else if ("NPU".equals(subcomando)) { + s = Sala.colocaEmNovaSala(j,true, modo); + } else if ("PRI".equals(subcomando)) { + s = Sala.colocaEmNovaSala(j,false, modo); + } else if (subcomando.startsWith("PRI-")) { + s = Sala.colocaEmSalaPrivada(j, subcomando.substring(4)); } else { - if ("PUB".equals(args[1])) { - Sala s = Sala.colocaEmSalaPublica(j, args[2]); - s.mandaInfoParaTodos(); - // TODO: implementar comandos para sala privada - } else { - j.println("X AI"); - } + return; + } + if (s != null) { + s.mandaInfoParaTodos(); } } catch (NumberFormatException | IndexOutOfBoundsException e) { - j.println("X AI"); + // Simplesmente ignoramos qualquer erro causado pelo comando } } diff --git a/server/src/main/java/me/chester/minitruco/server/ComandoN.java b/server/src/main/java/me/chester/minitruco/server/ComandoN.java index 12779ac4..b3cafdae 100644 --- a/server/src/main/java/me/chester/minitruco/server/ComandoN.java +++ b/server/src/main/java/me/chester/minitruco/server/ComandoN.java @@ -26,6 +26,7 @@ public void executa(String[] args, JogadorConectado j) { String nome = Jogador.sanitizaNome( (comando + " ").substring(2) ); + ServerLogger.evento("Nome mudou de " + j.getNome() + " para " + nome); j.setNome(nome); j.println("N " + nome); } diff --git a/server/src/main/java/me/chester/minitruco/server/JogadorConectado.java b/server/src/main/java/me/chester/minitruco/server/JogadorConectado.java index 0923cf8f..6f48189e 100644 --- a/server/src/main/java/me/chester/minitruco/server/JogadorConectado.java +++ b/server/src/main/java/me/chester/minitruco/server/JogadorConectado.java @@ -52,6 +52,7 @@ public class JogadorConectado extends Jogador implements Runnable { */ public JogadorConectado(Socket cliente) { this.cliente = cliente; + this.setNome(Jogador.sanitizaNome("")); // atribui um nome padrão } @FunctionalInterface diff --git a/server/src/main/java/me/chester/minitruco/server/Sala.java b/server/src/main/java/me/chester/minitruco/server/Sala.java index e49328a7..6e05ed16 100644 --- a/server/src/main/java/me/chester/minitruco/server/Sala.java +++ b/server/src/main/java/me/chester/minitruco/server/Sala.java @@ -23,7 +23,7 @@ public class Sala { /** - * Salas criadas por usuários (a chave é o código da sala) + * Salas privadas (a chave é o código da sala) */ private static final Map salasPrivadas = new HashMap<>(); @@ -58,7 +58,7 @@ public static void limpaSalas() { } /** - * Código usado para os amigos acharem a sala; null se for uma sala pública + * Código usado para os amigos acharem a sala privada; null se for uma sala pública */ String codigo; @@ -76,13 +76,13 @@ public static void limpaSalas() { private PartidaLocal partida = null; /** - * Cria uma sala . + * Cria uma nova sala, pública ou privada */ public Sala(boolean publica, String modo) { if (publica) { salasPublicasDisponiveis.add(this); } else { - String codigo = UUID.randomUUID().toString().substring(0, 5); + String codigo = UUID.randomUUID().toString().toUpperCase().substring(0, 5); this.codigo = codigo; salasPrivadas.put(codigo, this); } @@ -93,6 +93,7 @@ public Sala(boolean publica, String modo) { * Coloca o jogador em uma sala pública que tenha aquele modo de partida * criando uma caso estejam todas lotadas * + * @return sala em que foi colocado */ public static synchronized Sala colocaEmSalaPublica(JogadorConectado j, String modo) { Sala sala = salasPublicasDisponiveis.stream().filter(s -> @@ -106,13 +107,31 @@ public static synchronized Sala colocaEmSalaPublica(JogadorConectado j, String m } /** - * Coloca o jogador em uma sala privada pré-existente - * @param codigo o código recebido de quem criou a sala - * @return false caso a sala não tenha sido encontrada ou esteja lotada + * Coloca o jogador em uma sala privada pré-existente (através do código) + * + * @return sala em que foi colocado, ou null se não existir/estiver lotada */ - public static synchronized boolean colocaEmSalaPrivada(JogadorConectado j, String codigo) { + public static synchronized Sala colocaEmSalaPrivada(JogadorConectado j, String codigo) { Sala sala = salasPrivadas.get(codigo); - return (sala != null && sala.adiciona(j)); + if (sala != null && sala.adiciona(j)) { + return sala; + } + return null; + } + + /** + * Cria uma nova sala e coloca o jogador nela + * + * @param j Jogador a ser colocado na sala (vai ser sempre o gerente) + * @param publica true se a sala for pública, false se for privada + * @param modo "P" para paulista, "M" para mineiro, etc. + * @return nova sala em que foi colocado + */ + public static synchronized Sala colocaEmNovaSala(JogadorConectado j, boolean publica, String modo) { + Sala sala = new Sala(publica, modo); + sala.adiciona(j); + + return sala; } /** diff --git a/server/src/main/java/me/chester/minitruco/server/ServerLogger.java b/server/src/main/java/me/chester/minitruco/server/ServerLogger.java index 024de7a5..fadcfdd8 100644 --- a/server/src/main/java/me/chester/minitruco/server/ServerLogger.java +++ b/server/src/main/java/me/chester/minitruco/server/ServerLogger.java @@ -49,7 +49,7 @@ public static synchronized void evento(Jogador j, String mensagem) { System.out.print("[sem_sala] "); } if (j != null) { - System.out.print(!j.getNome().equals("unnamed") ? j.getNome() : "[sem_nome]"); + System.out.print(j.getNome()); if (j instanceof JogadorConectado) { System.out.print('@'); System.out.print(((JogadorConectado) j).getIp()); diff --git a/server/src/test/java/me/chester/minitruco/server/ComandoETest.java b/server/src/test/java/me/chester/minitruco/server/ComandoETest.java new file mode 100644 index 00000000..b7d5aa37 --- /dev/null +++ b/server/src/test/java/me/chester/minitruco/server/ComandoETest.java @@ -0,0 +1,127 @@ +package me.chester.minitruco.server; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.matchesPattern; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class ComandoETest { + + private static JogadorConectado j1, j2, j3, jAnon; + + @BeforeEach + void setUp() { + Sala.limpaSalas(); + j1 = spy(new JogadorConectado(null)); + j2 = spy(new JogadorConectado(null)); + j3 = spy(new JogadorConectado(null)); + jAnon = spy(new JogadorConectado(null)); + j1.setNome("j1"); + j2.setNome("j2"); + j3.setNome("j3"); + doNothing().when(j1).println(any()); + doNothing().when(j2).println(any()); + doNothing().when(j3).println(any()); + doNothing().when(jAnon).println(any()); + } + + @Test + void testProcuraSalaPublicaFazMatchDeRegra() { + Comando.interpreta("E PUB P", j1); + verify(j1).println("I j1|bot|bot|bot P 1 PUB"); + + Comando.interpreta("E PUB P", j2); + verify(j1).println("I j1|j2|bot|bot P 1 PUB"); + verify(j2).println("I j1|j2|bot|bot P 2 PUB"); + + Comando.interpreta("E PUB M", j3); + verify(j3).println("I j3|bot|bot|bot M 1 PUB"); + + verify(j1, times(2)).println(any()); + verify(j2, times(1)).println(any()); + } + + @Test + void testCriaSalaPublicaIgnoraSalaExistente() { + Comando.interpreta("E PUB P", j1); + Comando.interpreta("E NPU P", j2); + Comando.interpreta("E NPU P", j3); + + verify(j1).println("I j1|bot|bot|bot P 1 PUB"); + verify(j2).println("I j2|bot|bot|bot P 1 PUB"); + verify(j3).println("I j3|bot|bot|bot P 1 PUB"); + + verify(j1, times(1)).println(any()); + verify(j2, times(1)).println(any()); + verify(j3, times(1)).println(any()); + } + + @Test + void testSalaPrivada() { + ArgumentCaptor notificacaoCaptor = ArgumentCaptor.forClass(String.class); + Comando.interpreta("E PRI P", j1); + verify(j1).println(notificacaoCaptor.capture()); + + String notificacao = notificacaoCaptor.getValue(); + assertThat(notificacao, matchesPattern("I j1\\|bot\\|bot\\|bot P 1 PRI-[0-9A-Z]+")); + String salaComPrefixo = notificacao.split(" ")[4]; + + Comando.interpreta("E " + salaComPrefixo, j2); + verify(j1).println("I j1|j2|bot|bot P 1 " + salaComPrefixo); + verify(j2).println("I j1|j2|bot|bot P 2 " + salaComPrefixo); + + verify(j1, times(2)).println(any()); + verify(j2, times(1)).println(any()); + } + + @Test + void testArgumentosESalasInvalidoaSaoIgnoradas() { + Comando.interpreta("E", j1); + Comando.interpreta("E ", j1); + Comando.interpreta("E XYZ", j1); + Comando.interpreta("E XYZ A", j1); + Comando.interpreta("E PUB !", j1); + Comando.interpreta("E PRI-SALA404", j1); + verify(j1, never()).println(any()); + } + + @Test + void testTiraDaSalaAtualSeJaEstiverEmUma() { + Comando.interpreta("E PUB M", j1); + verify(j1).println("I j1|bot|bot|bot M 1 PUB"); + Comando.interpreta("E PUB M", j2); + verify(j1).println("I j1|j2|bot|bot M 1 PUB"); + verify(j2).println("I j1|j2|bot|bot M 2 PUB"); + Comando.interpreta("E PUB M", j3); + verify(j1).println("I j1|j2|j3|bot M 1 PUB"); + verify(j2).println("I j1|j2|j3|bot M 2 PUB"); + verify(j3).println("I j1|j2|j3|bot M 3 PUB"); + + Comando.interpreta("E PUB P", j1); + verify(j1).println("S"); + verify(j1).println("I j1|bot|bot|bot P 1 PUB"); + verify(j2).println("I j2|j3|bot|bot M 1 PUB"); + verify(j3).println("I j2|j3|bot|bot M 2 PUB"); + + verify(j1, times(5)).println(any()); + verify(j2, times(3)).println(any()); + verify(j3, times(2)).println(any()); + } + + @Test + void testUsaNomeDefaultSeNaoForInformado() { + ArgumentCaptor notificacaoCaptor = ArgumentCaptor.forClass(String.class); + Comando.interpreta("E PUB P", jAnon); + verify(jAnon).println(notificacaoCaptor.capture()); + String notificacao = notificacaoCaptor.getValue(); + assertThat(notificacao, matchesPattern("I sem_nome_.+\\|bot\\|bot\\|bot P 1 PUB")); + } +} diff --git a/server/src/test/java/me/chester/minitruco/server/JogadorConectadoTest.java b/server/src/test/java/me/chester/minitruco/server/JogadorConectadoTest.java index d4408707..5a25aa42 100644 --- a/server/src/test/java/me/chester/minitruco/server/JogadorConectadoTest.java +++ b/server/src/test/java/me/chester/minitruco/server/JogadorConectadoTest.java @@ -1,5 +1,7 @@ package me.chester.minitruco.server; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.text.MatchesPattern.matchesPattern; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -43,4 +45,10 @@ void testOnFinish() throws InterruptedException { assertTrue(chamouOnFinished[0]); } + @Test + void testNovoJogadorTemNomePadrao() { + JogadorConectado j = new JogadorConectado(mockSocket); + assertThat(j.getNome(), matchesPattern("^sem_nome_\\d{1,3}$")); + } + }