diff --git a/.gitignore b/.gitignore index 6e637a6..be95506 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ target/ ### IntelliJ IDEA ### .idea/modules.xml .idea/jarRepositories.xml -.idea/compiler.xml .idea/libraries/ *.iws *.iml diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..377d957 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilotDiffState.xml b/.idea/copilotDiffState.xml new file mode 100644 index 0000000..e3a4679 --- /dev/null +++ b/.idea/copilotDiffState.xml @@ -0,0 +1,43 @@ + + + + + + \ No newline at end of file diff --git a/TEST_GAME.md b/TEST_GAME.md new file mode 100644 index 0000000..733ec62 --- /dev/null +++ b/TEST_GAME.md @@ -0,0 +1,159 @@ +# 🎮 Prueba de la Vista de Partida - InazumaGo + +## Cómo ejecutar y probar la aplicación + +### Opción 1: Ejecutar desde el IDE (JetBrains IntelliJ IDEA) + +1. Abre el proyecto en JetBrains IDEA +2. Navega a `src/main/java/es/iesquevedo/MainGUI.java` +3. Haz clic derecho → "Run 'MainGUI.main()'" +4. La aplicación mostrará la pantalla de Login + +### Opción 2: Ejecutar desde línea de comandos + +```bash +cd C:\Users\Usuario\IdeaProjects\InazumaGo +mvn compile +java -cp target/classes es.iesquevedo.MainGUI +``` + +--- + +## Flujo de Prueba + +### Paso 1: Pantalla de Login +- Verás la pantalla de Login con campos de Email y Contraseña +- Ingresa cualquier email (ej: `usuario@example.com`) +- Ingresa cualquier contraseña (ej: `password123`) +- Haz clic en "Iniciar sesión" + +### Paso 2: Pantalla de Partida (Nueva Vista) +Tras login exitoso, verás la nueva vista de partida con: + +#### 📊 Encabezado (Top) +- **Jugador 1 (Negro)** + - Nombre: usuario@example.com + - Puntos: 0 + - Tiempo: 00:00 + +- **VS** (separador visual) + +- **Jugador 2 (Blanco)** + - Nombre: Oponente + - Puntos: 0 + - Tiempo: 00:00 + +- **Turno Actual**: Turno: usuario@example.com + +#### 🎯 Centro (Canvas) +- Tablero 19x19 con líneas de cuadrícula +- Haz clic en cualquier intersección para colocar una piedra +- Las piedras aparecerán en negro (Jugador 1) o blanco (Jugador 2) + +#### 🎮 Controles (Bottom) +- **Pasar turno**: Cambia de jugador +- **Deshacer**: No disponible en esta versión +- **Rendirse**: Marca fin de la partida con ganador +- **Volver al menú**: Cierra la ventana de partida + +--- + +## Características Implementadas ✅ + +### Vista FXML (Game.fxml) +- ✅ Diseño BorderPane con 3 secciones (Top, Center, Bottom) +- ✅ Encabezado con información de jugadores +- ✅ Canvas para dibujar el tablero 19x19 +- ✅ Botones de control con eventos onAction +- ✅ Labels dinámicos para puntuación y tiempo + +### Controlador (GameController.java) +- ✅ Inicialización del tablero vacío (Stone[19][19]) +- ✅ Dibujo del tablero con líneas de cuadrícula +- ✅ Detección de clics del ratón en el Canvas +- ✅ Colocación de piedras (negro/blanco) +- ✅ Control de turnos alternantes +- ✅ Cálculo de puntuación en tiempo real +- ✅ Temporizador con AnimationTimer que cuenta segundos/minutos +- ✅ Métodos para acciones (pasar turno, rendirse) +- ✅ Inyección de nombres de jugadores +- ✅ Inyección de puntuaciones iniciales + +### Navegación (LoginController.java modificado) +- ✅ Carga automática de Game.fxml tras login exitoso +- ✅ Paso de nombres de jugadores al GameController +- ✅ Cambio del título de la ventana a "InazumaGo - Partida" + +--- + +## Pruebas Recomendadas + +### Prueba 1: Colocar piedras +1. Haz clic en varias intersecciones del tablero +2. Verifica que aparecen piedras negras (jugador 1) y blancas (jugador 2) alternadamente +3. Comprueba que no se pueden colocar dos piedras en la misma posición + +**Resultado esperado**: Las piedras aparecen correctamente en color y posición + +### Prueba 2: Cambio de turno +1. Coloca una piedra +2. Verifica que el turno cambió al otro jugador +3. Comprueba que el color de la siguiente piedra es diferente + +**Resultado esperado**: Los turnos se alternan correctamente + +### Prueba 3: Puntuación +1. Coloca varias piedras +2. Verifica que el contador de puntos aumenta para cada jugador + +**Resultado esperado**: Cada piedra colocada suma 1 punto al jugador + +### Prueba 4: Temporizador +1. Espera 10-20 segundos +2. Verifica que el tiempo avanza (HH:MM) +3. Coloca una piedra y mira que el temporizador del otro jugador comienza + +**Resultado esperado**: El tiempo se actualiza correctamente por segundo + +### Prueba 5: Pasar turno +1. Haz clic en "Pasar turno" +2. Verifica que cambia el jugador actual sin colocar piedra + +**Resultado esperado**: El turno cambia, pero no hay nueva piedra + +### Prueba 6: Rendirse +1. Haz clic en "Rendirse" +2. Verifica que aparece un mensaje de ganador + +**Resultado esperado**: Se muestra "[Ganador] ganó. [Perdedor] se rindió" + +--- + +## Archivos Generados + +``` +✅ src/main/resources/fxml/Game.fxml (4,188 bytes) +✅ src/main/java/es/iesquevedo/controller/GameController.java (8,447 bytes) +📝 src/main/java/es/iesquevedo/controller/LoginController.java (modificado) +``` + +## Estado de Compilación + +``` +✅ BUILD SUCCESS +✅ GameController.class compilado +✅ GameController$Stone.class compilado +✅ GameController$1.class compilado (clase interna anónima) +``` + +--- + +## Notas Técnicas + +- **Framework**: JavaFX 21.0.2 +- **Compilador**: Maven Compiler Plugin 3.13.0 con `javac` +- **Versión Java**: 21 (LTS) +- **Patrón de Diseño**: MVC (Model-View-Controller) +- **Threading**: AnimationTimer para actualización de UI en tiempo real + + diff --git a/ci/pipeline.yml b/ci/pipeline.yml index 65feacb..abe7186 100644 --- a/ci/pipeline.yml +++ b/ci/pipeline.yml @@ -13,11 +13,12 @@ jobs: image: eclipse-temurin:21 steps: - checkout + - run: java -version - restore_cache: key: maven-cache paths: - ~/.m2/repository - - run: ./mvnw -B -DskipTests=false test + - run: ./mvnw -B clean test - save_cache: key: maven-cache paths: diff --git a/doc/firebase-setup.md b/doc/firebase-setup.md new file mode 100644 index 0000000..a1ea7ed --- /dev/null +++ b/doc/firebase-setup.md @@ -0,0 +1,149 @@ +# Configuración Firebase para InazumaGo + +## Estado Actual +- ✅ Repositorio HTTP en OkHttp (FirebaseMainRepository) implementado +- ✅ AuthService mock para desarrollo +- ✅ Tests básicos pasando (sin dependencia de Firebase real) +- ⏳ **Falta: Configurar Firebase Console** + +--- + +## Pasos para Configurar Firebase (10-15 min) + +### 1. Crear Proyecto en Firebase Console +1. Ve a **https://console.firebase.google.com** +2. Click **"Crear proyecto"** +3. Nombre: `InazumaGo` +4. Deshabilita Analytics (por ahora, opcional) +5. Click **"Crear"** + +### 2. Habilitar Realtime Database +1. En el proyecto, ve a **"Realtime Database"** (en el menú lateral) +2. Click **"Crear base de datos"** +3. Ubicación: **Europe (europe-west1)** o **us-central1** +4. Modo: **Start in test mode** (por ahora, abierta para desarrollo) +5. Click **"Crear"** + +Firebase generará una URL como: +``` +https://inazumago-abc123.firebaseio.com +``` + +### 3. Copiar URL a `application.properties` + +**Archivo:** `src/main/resources/application.properties` + +```properties +# Firebase URL (sin .json) +firebase.url=https://inazumago-abc123.firebaseio.com +firebase.timeout.seconds=30 +firebase.auth.token= +``` + +### 4. (Alternativa) Usar Variable de Entorno + +```powershell +# PowerShell +$env:FIREBASE_URL="https://inazumago-abc123.firebaseio.com" +``` + +O en `.env`: +``` +FIREBASE_URL=https://inazumago-abc123.firebaseio.com +FIREBASE_AUTH_TOKEN= +``` + +### 5. Reglas de Seguridad RTDB (Desarrollo) + +**Para DESARROLLO:** En Firebase Console → Realtime Database → Rules + +```json +{ + "rules": { + ".read": true, + ".write": true, + "games": { + "$gameId": { + ".validate": "newData.hasChild('players')", + "players": { + ".validate": "newData.val().length() <= 2" + }, + "moves": { + ".validate": "!data.exists() || newData.val().length() >= data.val().length()" + } + } + } + } +} +``` + +**Para PRODUCCIÓN:** (después, con autenticación real) + +```json +{ + "rules": { + ".read": "auth != null", + ".write": "auth != null", + "games": { + "$gameId": { + ".validate": "newData.hasChildren(['id', 'players', 'status'])", + "players": { + ".validate": "newData.val().length() >= 2 && newData.val().length() <= 2" + }, + "moves": { + ".validate": "root.child('games').child($gameId).child('currentTurn') != null" + } + } + } + } +} +``` + +--- + +## Testing con Firebase Real + +**Una vez configurado, Red puede hacer:** + +1. Tests WireMock (ya listos en `FirebaseMainRepositoryTest`) +2. Tests contra Firebase real (descomenta tras configurar): + +```java +@Test +void testCreateGameAgainstFirebase() throws Exception { + GameDto game = new GameDto("game-real-123", "Test", + Arrays.asList("p1", "p2"), "IN_PROGRESS", System.currentTimeMillis()); + + FirebaseMainRepository repo = new FirebaseMainRepository( + System.getenv("FIREBASE_URL") + ); + CompletableFuture result = repo.createGame(game); + GameDto created = result.get(); + + assertNotNull(created); + assertEquals("game-real-123", created.getId()); +} +``` + +--- + +## Checklist para Red + +- [ ] Firebase Console: Proyecto creado +- [ ] RTDB: Base de datos creada (EU o US) +- [ ] RTDB URL: Copiada a `application.properties` +- [ ] Reglas: Aplicadas (desarrollo first) +- [ ] Tests: Ejecutados contra Firebase real (opcional ahora, hacer después) + +--- + +## Próximo Paso (E2-US3) + +Cuando Firebase esté configurado, Red implementa: +- AuthService real (Firebase Authentication) +- Integración de tokens en peticiones +- SSE o WebSocket para listeners en tiempo real + +**Responsable:** Red Team +**Estimación:** 1-2 sprints +**Bloqueador:** Firebase configurado diff --git a/doc/game-implementation-guide.md b/doc/game-implementation-guide.md new file mode 100644 index 0000000..33035bf --- /dev/null +++ b/doc/game-implementation-guide.md @@ -0,0 +1,165 @@ +# Guía para Implementar una Partida Jugable (Motor + UI) + +## Estado Actual de Red +✅ FirebaseMainRepository (cliente HTTP OkHttp) +✅ AuthService (mock para desarrollo) +✅ 34 tests pasando + +--- + +## Lo que Motor Necesita Hacer + +### 1. **Sincronización Optimistic + Rollback** (E2-US4-T3) +Crear en `GameService`: +```java +public CompletableFuture executeMove(String gameId, MoveData move) { + // 1. Aplicar movimiento localmente (optimistic) + localGame.getMoves().add(move); + + // 2. Enviar a Firebase + MovePayload payload = new MovePayload(Arrays.asList(move), System.currentTimeMillis()); + return mainRepository.writeMoveMultiPath(gameId, payload) + .exceptionally(e -> { + // 3. Rollback si falla (403, timeout, etc.) + localGame.getMoves().remove(move); + throw new RuntimeException("Movimiento rechazado: " + e.getMessage()); + }); +} +``` + +### 2. **Reglas de Validación de Movimientos** (E3-US2) +Crear interfaz `MoveValidator`: +```java +public interface MoveValidator { + void validateMove(Game game, MoveData move) throws InvalidMoveException; +} +``` + +Implementación: +```java +public class InazumaGoMoveValidator implements MoveValidator { + @Override + public void validateMove(Game game, MoveData move) throws InvalidMoveException { + // Validar: turno, posición, acción permitida, etc. + if (!isPlayerTurn(game, move.getPlayerId())) { + throw new InvalidMoveException("No es tu turno"); + } + if (!isValidPosition(move.getPosition())) { + throw new InvalidMoveException("Posición fuera del campo"); + } + } +} +``` + +### 3. **Integración en GameService** +```java +public CompletableFuture executeMove(String gameId, MoveData move) { + return CompletableFuture.runAsync(() -> { + try { + moveValidator.validateMove(currentGame, move); + // Enviar a Firebase... + mainRepository.writeMoveMultiPath(gameId, ...); + currentGame.nextTurn(); // Cambiar turno + } catch (InvalidMoveException e) { + throw new RuntimeException(e); + } + }); +} +``` + +--- + +## Lo que UI Necesita Hacer + +### 1. **Pantalla de Partida** +Crear `GameController.fxml` + `GameController.java`: +```java +@FXML private Label playerNameLabel; +@FXML private Button kickButton, passButton; +@FXML private Canvas gameCanvas; // Dibujar campo + +private GameService gameService; +private String currentGameId; + +@FXML private void onKickPressed() { + MoveData move = new MoveData(playerName, "KICK", selectedPosition); + gameService.executeMove(currentGameId, move) + .exceptionally(e -> { + showError("Movimiento rechazado: " + e.getMessage()); + return null; + }); +} +``` + +### 2. **Listeners en Tiempo Real** +```java +private void subscribeToMoves(String gameId) { + String listenerId = gameService.addMovesListener(gameId, moves -> { + // Actualizar UI con nuevos movimientos + Platform.runLater(() -> renderMoves(moves)); + }); +} +``` + +### 3. **Flujo de Creación de Partida** +``` +1. Pantalla de login (usa AuthService) +2. Pantalla de lobby (listar partidas o crear nueva) +3. Pantalla de espera (esperando segundo jugador) +4. Pantalla de partida (turnos, movimientos) +5. Pantalla de resultado (victoria/derrota/abandono) +``` + +--- + +## Para que Compile Todo Junto + +Motor debe crear: +- [ ] `MoveValidator` interface + `InazumaGoMoveValidator` +- [ ] Método `executeMove()` en `GameService` +- [ ] Tests de `MoveValidator` (qué movimientos son válidos) + +UI debe crear: +- [ ] `GameController.fxml` (diseño básico del campo) +- [ ] `GameController.java` (wiring con GameService) +- [ ] `AuthenticationController.fxml` + `.java` (login mock) +- [ ] Actualizar `MainGUI.java` para cargar `AuthenticationController` primero + +--- + +## Orden de Implementación (Rápido) + +**Día 1 - Motor:** +1. `MoveValidator` interface +2. `InazumaGoMoveValidator` (validar turno, posición) +3. `executeMove()` en `GameService` (con optimistic update + rollback) + +**Día 1 - UI:** +1. `AuthenticationController` (login mock, reutiliza `AuthServiceImpl`) +2. `GameController.fxml` (canvas con campo + botones) + +**Día 2 - Integración:** +1. Flujo completo: login → crear partida → jugar +2. Sync de movimientos (listeners) +3. Tests E2E con stubs + +--- + +## Firebase (Red) - Paralelo +Mientras Motor y UI trabajan: +1. Configura Firebase Console (10 min) +2. Copia URL a `application.properties` +3. Escribe reglas RTDB + +**No hace falta esperar a nada - Red ya dejó el código listo.** + +--- + +## Checklist Final + +- [ ] Motor: `MoveValidator` + `executeMove()` +- [ ] UI: `AuthenticationController` + `GameController` +- [ ] Tests: Mínimo 3 tests de validación de movimientos +- [ ] Firebase: Configurado en Console +- [ ] Partida: Crear, jugar 2 turnos mínimo, ver resultado + diff --git a/pom.xml b/pom.xml index 5175dcf..f587d8d 100644 --- a/pom.xml +++ b/pom.xml @@ -9,6 +9,11 @@ 1.0-SNAPSHOT + 21 + UTF-8 + 0.0.6 + 21.0.2 + windows-x86_64 21 21 UTF-8 @@ -46,10 +51,18 @@ 9.8.0 + + + com.squareup.okhttp3 + okhttp + 4.11.0 + + + com.google.code.gson gson - 2.11.0 + 2.10.1 @@ -69,6 +82,14 @@ + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${maven.compiler.release} + + org.apache.maven.plugins maven-surefire-plugin diff --git a/src/main/java/es/iesquevedo/GameTest.java b/src/main/java/es/iesquevedo/GameTest.java new file mode 100644 index 0000000..18848bf --- /dev/null +++ b/src/main/java/es/iesquevedo/GameTest.java @@ -0,0 +1,28 @@ +package es.iesquevedo; + +import es.iesquevedo.controller.GameController; +import java.util.logging.Logger; + +public class GameTest { + private static final Logger LOGGER = Logger.getLogger(GameTest.class.getName()); + + public static void main(String[] args) { + LOGGER.info("Iniciando prueba del GameController..."); + + try { + // Crear instancia del controlador + GameController controller = new GameController(); + LOGGER.info("GameController creado exitosamente"); + + // Verificar que se puede llamar a métodos + controller.setPlayerNames("Jugador1", "Jugador2"); + controller.setInitialScores(0, 0); + LOGGER.info("Métodos del GameController funcionan correctamente"); + + LOGGER.info("✅ Prueba del GameController completada exitosamente"); + } catch (Exception e) { + LOGGER.severe("❌ Error en la prueba del GameController: " + e.getMessage()); + e.printStackTrace(); + } + } +} diff --git a/src/main/java/es/iesquevedo/Main.java b/src/main/java/es/iesquevedo/Main.java index 169342a..60cdad3 100644 --- a/src/main/java/es/iesquevedo/Main.java +++ b/src/main/java/es/iesquevedo/Main.java @@ -3,6 +3,7 @@ import es.iesquevedo.config.AppConfig; import es.iesquevedo.controller.HealthController; import es.iesquevedo.controller.MainController; +import es.iesquevedo.repository.MainRepository; import es.iesquevedo.service.impl.MainServiceImpl; import es.iesquevedo.ui.HealthUIAdapter; import es.iesquevedo.ui.UIAdapter; @@ -18,17 +19,17 @@ public static void main(String[] args) { String firebaseUrl = System.getenv("FIREBASE_URL"); // Crear repositorio (Firebase si FIREBASE_URL está definido, sino InMemory) - var repository = AppConfig.createMainRepository(firebaseUrl); + MainRepository repository = AppConfig.createMainRepository(firebaseUrl); // Crear servicio y controlador - var service = new MainServiceImpl(repository); - var mainController = new MainController(); + MainServiceImpl service = new MainServiceImpl(repository); + MainController mainController = new MainController(); mainController.setService(service); // Adaptadores UI - var ui = new UIAdapter(mainController); - var healthController = new HealthController(); - var healthUi = new HealthUIAdapter(healthController); + UIAdapter ui = new UIAdapter(mainController); + HealthController healthController = new HealthController(); + HealthUIAdapter healthUi = new HealthUIAdapter(healthController); // Uso simple: saludar y comprobar estado // Comprobar explícitamente si el nivel está habilitado y usar el formateo incorporado diff --git a/src/main/java/es/iesquevedo/MainApp.java b/src/main/java/es/iesquevedo/MainApp.java index 95ca182..307f4aa 100644 --- a/src/main/java/es/iesquevedo/MainApp.java +++ b/src/main/java/es/iesquevedo/MainApp.java @@ -3,6 +3,7 @@ import es.iesquevedo.config.AppConfig; import es.iesquevedo.controller.HealthController; import es.iesquevedo.controller.MainController; +import es.iesquevedo.repository.MainRepository; import es.iesquevedo.service.impl.MainServiceImpl; import es.iesquevedo.ui.HealthUIAdapter; import es.iesquevedo.ui.UIAdapter; @@ -36,17 +37,17 @@ private static void runConsoleMode() { String firebaseUrl = System.getenv("FIREBASE_URL"); // Crear repositorio (Firebase si FIREBASE_URL está definido, sino InMemory) - var repository = AppConfig.createMainRepository(firebaseUrl); + MainRepository repository = AppConfig.createMainRepository(firebaseUrl); // Crear servicio y controlador - var service = new MainServiceImpl(repository); - var mainController = new MainController(); + MainServiceImpl service = new MainServiceImpl(repository); + MainController mainController = new MainController(); mainController.setService(service); - +//error, eliminar este comentario // Adaptadores UI - var ui = new UIAdapter(mainController); - var healthController = new HealthController(); - var healthUi = new HealthUIAdapter(healthController); + UIAdapter ui = new UIAdapter(mainController); + HealthController healthController = new HealthController(); + HealthUIAdapter healthUi = new HealthUIAdapter(healthController); // Uso simple: saludar y comprobar estado if (LOGGER.isLoggable(Level.INFO)) { diff --git a/src/main/java/es/iesquevedo/MainGUI.java b/src/main/java/es/iesquevedo/MainGUI.java index 9c49098..c5de527 100644 --- a/src/main/java/es/iesquevedo/MainGUI.java +++ b/src/main/java/es/iesquevedo/MainGUI.java @@ -17,27 +17,27 @@ public class MainGUI extends Application { @Override public void start(Stage primaryStage) { try { - // Cargar FXML de Login con su controlador + // Cargar pantalla de login FXMLLoader loader = new FXMLLoader(getClass().getResource("/fxml/Login.fxml")); - Parent root = (Parent) loader.load(); - - // Obtener el controlador después de cargar el FXML - LoginController loginController = loader.getController(); - - // Inyectar el servicio de autenticación (mock para desarrollo) - loginController.setAuthService(new AuthServiceMock()); - - // Crear escena y mostrar ventana - Scene scene = new Scene(root, 600, 400); + Parent root = loader.load(); + + // Configurar LoginController + LoginController controller = loader.getController(); + controller.setAuthService(new AuthServiceMock()); + + Scene scene = new Scene(root, 500, 400); primaryStage.setTitle("InazumaGo - Login"); primaryStage.setScene(scene); primaryStage.show(); - if (LOGGER.isLoggable(Level.INFO)) { - LOGGER.log(Level.INFO, "Aplicación JavaFX iniciada exitosamente (Login)"); - } + LOGGER.log(Level.INFO, "Aplicación JavaFX iniciada - Pantalla de login cargada"); } catch (Exception e) { LOGGER.log(Level.SEVERE, "Error al iniciar la aplicación", e); + e.printStackTrace(); } } + + public static void main(String[] args) { + launch(args); + } } diff --git a/src/main/java/es/iesquevedo/config/AppConfig.java b/src/main/java/es/iesquevedo/config/AppConfig.java index f498e7d..48e2f60 100644 --- a/src/main/java/es/iesquevedo/config/AppConfig.java +++ b/src/main/java/es/iesquevedo/config/AppConfig.java @@ -27,10 +27,10 @@ private AppConfig() { * proporciona una URL, se devuelve el repositorio orientado a Firebase. */ public static MainRepository createMainRepository(String firebaseUrl) { - if (firebaseUrl == null || firebaseUrl.isBlank()) { + if (firebaseUrl == null || firebaseUrl.trim().isEmpty()) { return new InMemoryMainRepository(); } - return new FirebaseMainRepository(firebaseUrl); + return new FirebaseMainRepository(firebaseUrl, 30); } /** @@ -44,7 +44,14 @@ public static MainRepository createInMemoryRepository() { * Atajo para obtener la implementación orientada a Firebase (producción). */ public static MainRepository createFirebaseRepository(String firebaseUrl) { - return new FirebaseMainRepository(firebaseUrl); + return new FirebaseMainRepository(firebaseUrl, 30); + } + + /** + * Crea repositorio Firebase con timeout personalizado. + */ + public static FirebaseMainRepository createFirebaseRepository(String firebaseUrl, int timeoutSeconds) { + return new FirebaseMainRepository(firebaseUrl, timeoutSeconds); } /** diff --git a/src/main/java/es/iesquevedo/controller/GameController.java b/src/main/java/es/iesquevedo/controller/GameController.java new file mode 100644 index 0000000..24b1b19 --- /dev/null +++ b/src/main/java/es/iesquevedo/controller/GameController.java @@ -0,0 +1,601 @@ +package es.iesquevedo.controller; + +import es.iesquevedo.model.Board; +import es.iesquevedo.model.Game; +import es.iesquevedo.model.GameState; +import es.iesquevedo.model.Move; +import es.iesquevedo.model.Player; +import es.iesquevedo.service.impl.InazumaGoMoveValidator; +import es.iesquevedo.exception.InvalidMoveException; +import es.iesquevedo.exception.PlayerNotInTurnException; + +import javafx.animation.AnimationTimer; +import javafx.fxml.FXML; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.control.Label; +import javafx.scene.paint.Color; +import javafx.scene.paint.LinearGradient; +import javafx.scene.paint.RadialGradient; +import javafx.scene.paint.CycleMethod; +import javafx.scene.paint.Stop; +import javafx.scene.input.MouseEvent; +import javafx.scene.text.Font; +import javafx.scene.text.TextAlignment; + +import java.util.logging.Level; +import java.util.logging.Logger; + +public class GameController { + private static final Logger LOGGER = Logger.getLogger(GameController.class.getName()); + private static final int BOARD_SIZE = 9; + private static final int CELL_SIZE = 50; + + @FXML private Label player1NameLabel; + @FXML private Label player1ScoreLabel; + @FXML private Label player1TimeLabel; + @FXML private Label player2NameLabel; + @FXML private Label player2ScoreLabel; + @FXML private Label player2TimeLabel; + @FXML private Label currentTurnLabel; + @FXML private Label statusLabel; + @FXML private Canvas boardCanvas; + + private String player1Name = "Jugador 1"; + private String player2Name = "Jugador 2"; + private long player1TimeMs = 0; + private long player2TimeMs = 0; + private AnimationTimer gameTimer; + private long lastTime = 0; + private boolean gameEnded = false; + + // Lógica real del juego + private Game game; + private InazumaGoMoveValidator moveValidator; + private boolean moveInProgress = false; + + @FXML + public void initialize() { + LOGGER.log(Level.INFO, "GameController inicializado"); + + // Inicializar validador de movimientos + moveValidator = new InazumaGoMoveValidator(); + + // Crear juego con dos jugadores locales + Player player1 = new Player("1", player1Name); + Player player2 = new Player("2", player2Name); + + game = new Game("Local Game", player1); + game.addPlayer(player2); + game.start(); + + updatePlayerInfo(); + drawBoard(); + boardCanvas.setOnMouseClicked(this::onBoardClick); + startGameTimer(); + } + + private void updatePlayerInfo() { + player1NameLabel.setText(player1Name + " (Negro)"); + player2NameLabel.setText(player2Name + " (Blanco)"); + updateScores(); + updateCurrentTurn(); + } + + private void updateScores() { + // Calcular puntuación simple: contar piedras por color + Board board = game.getBoard(); + int blackStones = 0; + int whiteStones = 0; + + for (int r = 0; r < BOARD_SIZE; r++) { + for (int c = 0; c < BOARD_SIZE; c++) { + int cell = board.getCell(r, c); + if (cell == 1) blackStones++; + else if (cell == 2) whiteStones++; + } + } + + // Komi (ventaja blanca): 5.5 + double blackScore = blackStones; + double whiteScore = whiteStones + 5.5; + + player1ScoreLabel.setText(String.format("Puntos: %.1f", blackScore)); + player2ScoreLabel.setText(String.format("Puntos: %.1f", whiteScore)); + } + + private void updateCurrentTurn() { + Player currentPlayer = game.getCurrentPlayer(); + String turn = currentPlayer != null ? currentPlayer.getName() : player1Name; + currentTurnLabel.setText("Turno: " + turn); + } + + private void drawBoard() { + GraphicsContext gc = boardCanvas.getGraphicsContext2D(); + double boardStartX = getBoardStartX(); + double boardStartY = getBoardStartY(); + double boardEndX = getBoardEndX(); + double boardEndY = getBoardEndY(); + double boardSpan = (BOARD_SIZE - 1) * CELL_SIZE; + + // Fondo de madera con degradado ligero (más realista) + LinearGradient wood = new LinearGradient( + 0, 0, 1, 1, true, CycleMethod.NO_CYCLE, + new Stop(0, Color.web("#D2A679")), + new Stop(0.5, Color.web("#C37E3A")), + new Stop(1, Color.web("#B06A2E")) + ); + gc.setFill(wood); + gc.fillRect(0, 0, boardCanvas.getWidth(), boardCanvas.getHeight()); + + // Granulado / vetas finas + gc.setStroke(Color.color(0.15, 0.07, 0.03, 0.06)); + gc.setLineWidth(0.8); + for (int i = 0; i < (int) boardCanvas.getWidth(); i += 6) { + double offset = (i % 24) * 0.3; + gc.beginPath(); + gc.moveTo(0, (i + offset) % boardCanvas.getHeight()); + gc.bezierCurveTo(boardCanvas.getWidth() * 0.25, (i + offset + 8) % boardCanvas.getHeight(), + boardCanvas.getWidth() * 0.75, (i + offset - 12 + boardCanvas.getHeight()) % boardCanvas.getHeight(), + boardCanvas.getWidth(), (i + offset) % boardCanvas.getHeight()); + gc.stroke(); + } + + // Marco exterior con esquinas redondeadas perceptuales + gc.setStroke(Color.color(0.12, 0.06, 0.03, 1.0)); + gc.setLineWidth(5); + double outerX = boardStartX - 4; + double outerY = boardStartY - 4; + double outerW = boardSpan + 8; + double outerH = boardSpan + 8; + gc.strokeRoundRect(outerX, outerY, outerW, outerH, 12, 12); + + // Marco interior menos intenso + gc.setStroke(Color.color(0.36, 0.18, 0.08, 0.7)); + gc.setLineWidth(1.5); + gc.strokeRoundRect(boardStartX - 1, boardStartY - 1, + boardSpan + 2, boardSpan + 2, 8, 8); + + // Líneas del tablero, ligeramente suavizadas con doble trazo (sombra + línea) + for (int i = 0; i < BOARD_SIZE; i++) { + double y = getIntersectionY(i); + double x = getIntersectionX(i); + + // sombra de la línea horizontal + gc.setStroke(Color.color(0, 0, 0, 0.12)); + gc.setLineWidth(2.0); + gc.strokeLine(boardStartX + 0.8, y + 0.8, boardEndX + 0.8, y + 0.8); + + // línea principal + gc.setStroke(Color.color(0.06, 0.03, 0.01, 1.0)); + gc.setLineWidth(1.2); + gc.strokeLine(boardStartX, y, boardEndX, y); + + // sombra vertical + gc.setStroke(Color.color(0, 0, 0, 0.12)); + gc.setLineWidth(2.0); + gc.strokeLine(x + 0.8, boardStartY + 0.8, x + 0.8, boardEndY + 0.8); + + // línea vertical principal + gc.setStroke(Color.color(0.06, 0.03, 0.01, 1.0)); + gc.setLineWidth(1.2); + gc.strokeLine(x, boardStartY, x, boardEndY); + } + + // Coordenadas alrededor del tablero + drawCoordinates(gc, boardStartX, boardStartY, boardEndX, boardEndY); + + // Puntos hoshizashi mejor integrados + gc.setFill(Color.color(0.04, 0.02, 0.01, 1.0)); + int[] stars = {2, 4, 6}; + for (int i : stars) { + for (int j : stars) { + double px = getIntersectionX(i); + double py = getIntersectionY(j); + gc.fillOval(px - 3, py - 3, 6, 6); + gc.setStroke(Color.color(0, 0, 0, 0.18)); + gc.setLineWidth(0.6); + gc.strokeOval(px - 4, py - 4, 8, 8); + } + } + + // Dibujar piedras del tablero real (Board) + Board board = game.getBoard(); + for (int r = 0; r < BOARD_SIZE; r++) { + for (int c = 0; c < BOARD_SIZE; c++) { + int cell = board.getCell(r, c); + if (cell == 1) { // Negro + drawStone(gc, r, c, 1); + } else if (cell == 2) { // Blanco + drawStone(gc, r, c, 2); + } + } + } + } + + private void drawStone(GraphicsContext gc, int row, int col, int stoneColor) { + double x = getIntersectionX(col); + double y = getIntersectionY(row); + double radius = CELL_SIZE / 2 - 3; + + if (stoneColor == 1) { // Negro + // sombra proyectada + gc.setFill(Color.color(0, 0, 0, 0.28)); + gc.fillOval(x - radius + 2, y - radius + 3, radius * 1.9, radius * 1.9); + + // gradiente radial para dar volumen + RadialGradient blackGrad = new RadialGradient( + 45, 0.1, + x - radius * 0.15, y - radius * 0.2, + radius * 1.05, false, CycleMethod.NO_CYCLE, + new Stop(0.0, Color.web("#6e6e6e")), + new Stop(0.4, Color.web("#222222")), + new Stop(1.0, Color.web("#000000")) + ); + gc.setFill(blackGrad); + gc.fillOval(x - radius, y - radius, radius * 2, radius * 2); + + // highlight suave (brillo) + RadialGradient shine = new RadialGradient( + 45, 0.2, + x - radius * 0.25, y - radius * 0.3, + radius * 0.6, false, CycleMethod.NO_CYCLE, + new Stop(0.0, Color.color(1, 1, 1, 0.9)), + new Stop(0.4, Color.color(1, 1, 1, 0.25)), + new Stop(1.0, Color.color(1, 1, 1, 0.0)) + ); + gc.setGlobalAlpha(1.0); + gc.setFill(shine); + gc.fillOval(x - radius * 0.2, y - radius * 0.25, radius * 1.2, radius * 1.2); + + // borde ligero para definición + gc.setStroke(Color.color(0, 0, 0, 0.45)); + gc.setLineWidth(0.6); + gc.strokeOval(x - radius, y - radius, radius * 2, radius * 2); + + } else if (stoneColor == 2) { // Blanco + // piedra blanca: sombra más suave + gc.setFill(Color.color(0.06, 0.06, 0.06, 0.14)); + gc.fillOval(x - radius + 1.8, y - radius + 2.6, radius * 1.95, radius * 1.95); + + // base con degradado radial cálido + RadialGradient whiteGrad = new RadialGradient( + 45, 0.12, + x - radius * 0.12, y - radius * 0.18, + radius * 1.05, false, CycleMethod.NO_CYCLE, + new Stop(0.0, Color.web("#ffffff")), + new Stop(0.6, Color.web("#f2f2f2")), + new Stop(1.0, Color.web("#d1d1d1")) + ); + gc.setFill(whiteGrad); + gc.fillOval(x - radius, y - radius, radius * 2, radius * 2); + + // borde suave gris + gc.setStroke(Color.color(0.6, 0.6, 0.6, 0.6)); + gc.setLineWidth(0.9); + gc.strokeOval(x - radius + 0.4, y - radius + 0.4, radius * 2 - 0.8, radius * 2 - 0.8); + + // highlights blancos + RadialGradient whiteShine = new RadialGradient( + 45, 0.2, + x - radius * 0.2, y - radius * 0.25, + radius * 0.5, false, CycleMethod.NO_CYCLE, + new Stop(0.0, Color.color(1, 1, 1, 0.95)), + new Stop(0.6, Color.color(1, 1, 1, 0.45)), + new Stop(1.0, Color.color(1, 1, 1, 0.0)) + ); + gc.setFill(whiteShine); + gc.fillOval(x - radius * 0.15, y - radius * 0.18, radius * 0.95, radius * 0.95); + + // textura sutil + gc.setFill(Color.color(0.8, 0.8, 0.8, 0.05)); + gc.fillOval(x - radius + 2, y - radius + 2, radius * 1.4, radius * 1.4); + } + } + + @FXML + private void onBoardClick(MouseEvent event) { + if (gameEnded || moveInProgress) { + return; + } + + double x = event.getX(); + double y = event.getY(); + double boardStartX = getBoardStartX(); + double boardStartY = getBoardStartY(); + + int col = (int) Math.round((x - boardStartX) / CELL_SIZE); + int row = (int) Math.round((y - boardStartY) / CELL_SIZE); + + if (isValidPosition(row, col)) { + placeStone(row, col); + } + } + + private boolean isValidPosition(int row, int col) { + return row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE; + } + + private void placeStone(int row, int col) { + if (moveInProgress || gameEnded) { + return; + } + + moveInProgress = true; + + try { + Player currentPlayer = game.getCurrentPlayer(); + + if (currentPlayer == null) { + statusLabel.setText("Error: sin jugador actual"); + moveInProgress = false; + return; + } + + // Crear movimiento con playerId + Move move = new Move(currentPlayer.getId(), row, col); + + // Validar movimiento + moveValidator.validateMove(game, move); + + // Guardar estado previo para detectar repetición + Board previousBoardState = game.getBoard().clone(); + + // Ejecutar movimiento en el tablero + Board board = game.getBoard(); + int playerColor = (game.getCurrentPlayerIndex() == 0) ? 1 : 2; // 1=Negro, 2=Blanco + board.placeStone(row, col, playerColor); + + // Capturar grupos enemigos sin libertades + int capturedCount = board.captureGroupsWithoutLiberties(); + + // Registrar movimiento + move.setCapturedCount(capturedCount); + game.getMoves().add(move); + + // Detectar repetición de posición (se ignora la reversión por ahora) + if (game.getLastBoardState() != null && board.equals(game.getLastBoardState())) { + statusLabel.setText("Movimiento rechazado: repetición de posición"); + moveInProgress = false; + return; + } + + game.setLastBoardState(previousBoardState); + + // Movimiento exitoso + statusLabel.setText("Movimiento realizado" + (capturedCount > 0 ? " (" + capturedCount + " piedras capturadas)" : "")); + + // Cambiar turno + game.nextTurn(); + game.resetConsecutivePasses(); + + updatePlayerInfo(); + drawBoard(); + moveInProgress = false; + + LOGGER.log(Level.INFO, "Piedra colocada en [" + row + "," + col + "], capturadas: " + capturedCount); + + } catch (InvalidMoveException | PlayerNotInTurnException ex) { + statusLabel.setText("Movimiento inválido: " + ex.getMessage()); + moveInProgress = false; + LOGGER.log(Level.WARNING, "Error en movimiento: " + ex.getMessage()); + } catch (Exception ex) { + statusLabel.setText("Error: " + ex.getMessage()); + moveInProgress = false; + LOGGER.log(Level.SEVERE, "Error inesperado en movimiento: " + ex.getMessage()); + } + } + + @FXML + private void onPassTurn() { + if (gameEnded || moveInProgress) { + return; + } + + moveInProgress = true; + + try { + Player currentPlayer = game.getCurrentPlayer(); + if (currentPlayer == null) { + statusLabel.setText("Error: sin jugador actual"); + moveInProgress = false; + return; + } + + // Crear movimiento de PASE + Move move = new Move(currentPlayer.getId(), true); // PASE + + // Validar (pasadas sin validación especial) + moveValidator.validateMove(game, move); + + // Registrar pase + game.getMoves().add(move); + game.incrementConsecutivePasses(); + + // Cambiar turno + game.nextTurn(); + + statusLabel.setText(currentPlayer.getName() + " pasó su turno. Pases consecutivos: " + game.getConsecutivePasses()); + + // Verificar doble pase = fin de partida + if (game.getConsecutivePasses() >= 2) { + endGame(); + } + + updateCurrentTurn(); + moveInProgress = false; + + LOGGER.log(Level.INFO, "Turno pasado. Pases consecutivos: " + game.getConsecutivePasses()); + } catch (Exception ex) { + statusLabel.setText("Error al pasar: " + ex.getMessage()); + moveInProgress = false; + } + } + + @FXML + private void onUndo() { + statusLabel.setText("Deshacer no disponible en esta versión"); + LOGGER.log(Level.INFO, "Deshacer solicitado"); + } + + @FXML + private void onSurrender() { + gameEnded = true; + game.setState(GameState.FINISHED); + + Player winner = game.getPlayers().get((game.getCurrentPlayerIndex() + 1) % 2); + Player loser = game.getCurrentPlayer(); + + game.setWinnerPlayerId(winner.getId()); + + statusLabel.setText(winner.getName() + " ganó. " + loser.getName() + " se rindió"); + LOGGER.log(Level.INFO, loser.getName() + " se rindió. Ganador: " + winner.getName()); + } + + @FXML + private void onBackToMenu() { + stopGameTimer(); + LOGGER.log(Level.INFO, "Volviendo al menú"); + // Castear a Stage para poder cerrar la ventana + javafx.stage.Stage stage = (javafx.stage.Stage) boardCanvas.getScene().getWindow(); + stage.close(); + } + + private void endGame() { + gameEnded = true; + game.setState(GameState.FINISHED); + + // Calcular puntuación final + Board board = game.getBoard(); + int blackStones = 0; + int whiteStones = 0; + + for (int r = 0; r < BOARD_SIZE; r++) { + for (int c = 0; c < BOARD_SIZE; c++) { + int cell = board.getCell(r, c); + if (cell == 1) blackStones++; + else if (cell == 2) whiteStones++; + } + } + + double blackScore = blackStones; + double whiteScore = whiteStones + 5.5; + + String winner = blackScore > whiteScore ? player1Name : player2Name; + String result = String.format("Partida finalizada. %s gana %.1f - %.1f", + winner, Math.max(blackScore, whiteScore), Math.min(blackScore, whiteScore)); + + statusLabel.setText(result); + LOGGER.log(Level.INFO, result); + + stopGameTimer(); + } + + private void startGameTimer() { + gameTimer = new AnimationTimer() { + @Override + public void handle(long now) { + if (lastTime == 0) { + lastTime = now; + return; + } + + long elapsedNanos = now - lastTime; + lastTime = now; + + if (!gameEnded) { + if (game.getCurrentPlayerIndex() == 0) { + player1TimeMs += elapsedNanos / 1_000_000; + } else { + player2TimeMs += elapsedNanos / 1_000_000; + } + updateTimeLabels(); + } + } + }; + gameTimer.start(); + } + + private void stopGameTimer() { + if (gameTimer != null) { + gameTimer.stop(); + } + } + + private void updateTimeLabels() { + player1TimeLabel.setText("Tiempo: " + formatTime(player1TimeMs)); + player2TimeLabel.setText("Tiempo: " + formatTime(player2TimeMs)); + } + + private String formatTime(long ms) { + long seconds = ms / 1000; + long minutes = seconds / 60; + seconds = seconds % 60; + return String.format("%02d:%02d", minutes, seconds); + } + + public void setPlayerNames(String player1, String player2) { + this.player1Name = player1 != null ? player1 : "Jugador 1"; + this.player2Name = player2 != null ? player2 : "Jugador 2"; + if (player1NameLabel != null) { + updatePlayerInfo(); + } + } + + public void setInitialScores(int score1, int score2) { + // Puntuación inicial se calcula del Board, no se establece manualmente + if (player1ScoreLabel != null) { + updateScores(); + } + } + + private double getBoardStartX() { + return (boardCanvas.getWidth() - ((BOARD_SIZE - 1) * CELL_SIZE)) / 2.0; + } + + private double getBoardStartY() { + return (boardCanvas.getHeight() - ((BOARD_SIZE - 1) * CELL_SIZE)) / 2.0; + } + + private double getBoardEndX() { + return getBoardStartX() + ((BOARD_SIZE - 1) * CELL_SIZE); + } + + private double getBoardEndY() { + return getBoardStartY() + ((BOARD_SIZE - 1) * CELL_SIZE); + } + + private double getIntersectionX(int col) { + return getBoardStartX() + col * CELL_SIZE; + } + + private double getIntersectionY(int row) { + return getBoardStartY() + row * CELL_SIZE; + } + + private void drawCoordinates(GraphicsContext gc, double boardStartX, double boardStartY, double boardEndX, double boardEndY) { + String[] letters = {"A", "B", "C", "D", "E", "F", "G", "H", "J"}; + gc.setFill(Color.color(0.22, 0.12, 0.05, 0.9)); + gc.setFont(Font.font("System", 13)); + gc.setTextAlign(TextAlignment.CENTER); + gc.setTextBaseline(javafx.geometry.VPos.CENTER); + + double offset = 18; + for (int i = 0; i < BOARD_SIZE; i++) { + double x = getIntersectionX(i); + double y = getIntersectionY(i); + + // Letras arriba y abajo + gc.fillText(letters[i], x, boardStartY - offset); + gc.fillText(letters[i], x, boardEndY + offset); + + // Números a izquierda y derecha + String number = String.valueOf(BOARD_SIZE - i); + gc.fillText(number, boardStartX - offset, y); + gc.fillText(number, boardEndX + offset, y); + } + } +} + + + diff --git a/src/main/java/es/iesquevedo/controller/LoginController.java b/src/main/java/es/iesquevedo/controller/LoginController.java index 0dbcabd..307a469 100644 --- a/src/main/java/es/iesquevedo/controller/LoginController.java +++ b/src/main/java/es/iesquevedo/controller/LoginController.java @@ -3,6 +3,9 @@ import es.iesquevedo.config.AppState; import es.iesquevedo.service.auth.AuthService; import javafx.fxml.FXML; +import javafx.fxml.FXMLLoader; +import javafx.scene.Parent; +import javafx.scene.Scene; import javafx.scene.control.Label; import javafx.scene.control.PasswordField; import javafx.scene.control.TextField; @@ -10,6 +13,7 @@ import java.util.logging.Level; import java.util.logging.Logger; +import es.iesquevedo.util.EmailUtils; /** * Controlador para la pantalla de login. @@ -66,6 +70,13 @@ public void onLoginClicked() { return; } + // Validar formato de email (asegura que contenga '@' y estructura básica) + if (!EmailUtils.isValidEmail(email)) { + updateStatus("El email no tiene un formato válido. Ej: usuario@ejemplo.com", "error"); + LOGGER.log(Level.WARNING, "Intento de login con email inválido: " + email); + return; + } + if (password == null || password.trim().isEmpty()) { updateStatus("La contraseña no puede estar vacía", "error"); LOGGER.log(Level.WARNING, "Intento de login sin contraseña"); @@ -94,8 +105,8 @@ public void onLoginClicked() { emailField.clear(); passwordField.clear(); - // Nota: En una app real aquí navegerías a la pantalla principal - // Por ahora solo mostramos el mensaje de éxito + // Navegar a la pantalla de juego + navigateToGame(email); } catch (Exception e) { updateStatus("✗ Error de login: " + e.getMessage(), "error"); @@ -103,6 +114,32 @@ public void onLoginClicked() { } } + /** + * Navega a la pantalla de juego después del login exitoso. + * + * @param playerName nombre del jugador autenticado + */ + private void navigateToGame(String playerName) { + try { + FXMLLoader gameLoader = new FXMLLoader(getClass().getResource("/fxml/Game.fxml")); + Parent gameRoot = gameLoader.load(); + + GameController gameController = gameLoader.getController(); + gameController.setPlayerNames(playerName, "Oponente"); + gameController.setInitialScores(0, 0); + + Scene scene = emailField.getScene(); + scene.setRoot(gameRoot); + javafx.stage.Stage stage = (javafx.stage.Stage) scene.getWindow(); + stage.setTitle("InazumaGo - Partida"); + + LOGGER.log(Level.INFO, "Navegado a pantalla de juego para: " + playerName); + } catch (Exception e) { + updateStatus("✗ Error al cargar pantalla de juego: " + e.getMessage(), "error"); + LOGGER.log(Level.SEVERE, "Error al cargar Game.fxml: " + e.getMessage()); + } + } + /** * Manejador del botón "Limpiar" (opcional). */ @@ -133,5 +170,7 @@ private void updateStatus(String message, String type) { statusLabel.setTextFill(Color.BLACK); } } + + // ...existing code... } diff --git a/src/main/java/es/iesquevedo/exception/InvalidMoveException.java b/src/main/java/es/iesquevedo/exception/InvalidMoveException.java index 1b7532b..eeacfb6 100644 --- a/src/main/java/es/iesquevedo/exception/InvalidMoveException.java +++ b/src/main/java/es/iesquevedo/exception/InvalidMoveException.java @@ -12,4 +12,3 @@ public InvalidMoveException(String message, Throwable cause) { super(message, cause); } } - diff --git a/src/main/java/es/iesquevedo/exception/PlayerNotInTurnException.java b/src/main/java/es/iesquevedo/exception/PlayerNotInTurnException.java index 36ab93c..b75222c 100644 --- a/src/main/java/es/iesquevedo/exception/PlayerNotInTurnException.java +++ b/src/main/java/es/iesquevedo/exception/PlayerNotInTurnException.java @@ -12,4 +12,3 @@ public PlayerNotInTurnException(String message, Throwable cause) { super(message, cause); } } - diff --git a/src/main/java/es/iesquevedo/model/Board.java b/src/main/java/es/iesquevedo/model/Board.java new file mode 100644 index 0000000..3a33bde --- /dev/null +++ b/src/main/java/es/iesquevedo/model/Board.java @@ -0,0 +1,222 @@ +package es.iesquevedo.model; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import java.util.Set; + +/** + * Representa el tablero de Inazuma Go (9x9 intersecciones). + * Estado de cada celda: vacía (0), negra (1), blanca (2). + */ +public class Board { + private static final int SIZE = 9; + private int[][] board; // 0 = empty, 1 = black, 2 = white + + public Board() { + this.board = new int[SIZE][SIZE]; + } + + public Board(Board other) { + this.board = new int[SIZE][SIZE]; + for (int r = 0; r < SIZE; r++) { + for (int c = 0; c < SIZE; c++) { + this.board[r][c] = other.board[r][c]; + } + } + } + + /** + * Obtiene el tamaño del tablero. + */ + public int getSize() { + return SIZE; + } + + /** + * Obtiene el estado de una celda (0=vacía, 1=negra, 2=blanca). + */ + public int getCell(int row, int col) { + if (!isValid(row, col)) return -1; + return board[row][col]; + } + + /** + * Coloca una piedra en el tablero. + */ + public void placeStone(int row, int col, int color) { + if (isValid(row, col)) { + board[row][col] = color; + } + } + + /** + * Remueve una piedra del tablero. + */ + public void removeStone(int row, int col) { + if (isValid(row, col)) { + board[row][col] = 0; + } + } + + /** + * Verifica si una coordenada es válida. + */ + public boolean isValid(int row, int col) { + return row >= 0 && row < SIZE && col >= 0 && col < SIZE; + } + + /** + * Verifica si una celda está vacía. + */ + public boolean isEmpty(int row, int col) { + return isValid(row, col) && board[row][col] == 0; + } + + /** + * Obtiene los vecinos ortogonales de una celda. + */ + public List getNeighbors(int row, int col) { + List neighbors = new ArrayList<>(); + int[][] directions = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; + for (int[] dir : directions) { + int nr = row + dir[0]; + int nc = col + dir[1]; + if (isValid(nr, nc)) { + neighbors.add(new int[]{nr, nc}); + } + } + return neighbors; + } + + /** + * Calcula las libertades (grados de libertad) de un grupo. + * Retorna el número de intersecciones vacías adyacentes al grupo. + */ + public int countLibertiesForGroup(int row, int col) { + int color = board[row][col]; + if (color == 0) return 0; + + Set visited = new HashSet<>(); + Set liberties = new HashSet<>(); + Queue queue = new LinkedList<>(); + queue.add(new int[]{row, col}); + visited.add(row + "," + col); + + while (!queue.isEmpty()) { + int[] current = queue.poll(); + int r = current[0]; + int c = current[1]; + + for (int[] neighbor : getNeighbors(r, c)) { + int nr = neighbor[0]; + int nc = neighbor[1]; + String key = nr + "," + nc; + + if (isEmpty(nr, nc)) { + liberties.add(key); + } else if (board[nr][nc] == color && !visited.contains(key)) { + visited.add(key); + queue.add(new int[]{nr, nc}); + } + } + } + + return liberties.size(); + } + + /** + * Obtiene todas las piedras de un grupo. + */ + public Set getGroup(int row, int col) { + int color = board[row][col]; + if (color == 0) return new HashSet<>(); + + Set group = new HashSet<>(); + Queue queue = new LinkedList<>(); + queue.add(new int[]{row, col}); + group.add(row + "," + col); + + while (!queue.isEmpty()) { + int[] current = queue.poll(); + int r = current[0]; + int c = current[1]; + + for (int[] neighbor : getNeighbors(r, c)) { + int nr = neighbor[0]; + int nc = neighbor[1]; + String key = nr + "," + nc; + + if (board[nr][nc] == color && !group.contains(key)) { + group.add(key); + queue.add(new int[]{nr, nc}); + } + } + } + + return group; + } + + /** + * Detecta y captura grupos sin libertades. + * Retorna el número de piedras capturadas. + */ + public int captureGroupsWithoutLiberties() { + int captured = 0; + for (int r = 0; r < SIZE; r++) { + for (int c = 0; c < SIZE; c++) { + if (board[r][c] != 0 && countLibertiesForGroup(r, c) == 0) { + Set group = getGroup(r, c); + for (String stone : group) { + String[] parts = stone.split(","); + int sr = Integer.parseInt(parts[0]); + int sc = Integer.parseInt(parts[1]); + removeStone(sr, sc); + captured++; + } + } + } + } + return captured; + } + + /** + * Crea una copia del tablero. + */ + public Board clone() { + return new Board(this); + } + + /** + * Compara dos tableros. + */ + @Override + public boolean equals(Object obj) { + if (!(obj instanceof Board)) return false; + Board other = (Board) obj; + for (int r = 0; r < SIZE; r++) { + for (int c = 0; c < SIZE; c++) { + if (this.board[r][c] != other.board[r][c]) return false; + } + } + return true; + } + + /** + * Representación en texto del tablero. + */ + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + for (int r = 0; r < SIZE; r++) { + for (int c = 0; c < SIZE; c++) { + char ch = board[r][c] == 0 ? '.' : (board[r][c] == 1 ? 'X' : 'O'); + sb.append(ch).append(" "); + } + sb.append("\n"); + } + return sb.toString(); + } +} diff --git a/src/main/java/es/iesquevedo/model/Game.java b/src/main/java/es/iesquevedo/model/Game.java index 66fe899..010d10d 100644 --- a/src/main/java/es/iesquevedo/model/Game.java +++ b/src/main/java/es/iesquevedo/model/Game.java @@ -15,6 +15,10 @@ public class Game { private String winnerPlayerId; private LocalDateTime createdAt; private LocalDateTime finishedAt; + private Board board; + private List moves; + private int consecutivePasses; + private Board lastBoardState; public Game(String name, Player player1) { this.id = UUID.randomUUID().toString(); @@ -27,6 +31,10 @@ public Game(String name, Player player1) { this.winnerPlayerId = null; this.createdAt = LocalDateTime.now(); this.finishedAt = null; + this.board = new Board(); + this.moves = new ArrayList<>(); + this.consecutivePasses = 0; + this.lastBoardState = null; } public String getId() { return id; } @@ -44,6 +52,13 @@ public Player getCurrentPlayer() { public void setWinnerPlayerId(String playerId) { this.winnerPlayerId = playerId; } public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getFinishedAt() { return finishedAt; } + public Board getBoard() { return board; } + public List getMoves() { return moves; } + public int getConsecutivePasses() { return consecutivePasses; } + public void incrementConsecutivePasses() { this.consecutivePasses++; } + public void resetConsecutivePasses() { this.consecutivePasses = 0; } + public Board getLastBoardState() { return lastBoardState; } + public void setLastBoardState(Board boardState) { this.lastBoardState = boardState; } public void addPlayer(Player player) { if (state != GameState.WAITING) { @@ -61,6 +76,9 @@ public void start() { } this.state = GameState.IN_PROGRESS; this.currentPlayerIndex = 0; + this.board = new Board(); // Reinicializar tablero limpio + this.moves = new ArrayList<>(); + this.consecutivePasses = 0; } public void nextTurn() { @@ -82,4 +100,3 @@ public void abandon() { this.finishedAt = LocalDateTime.now(); } } - diff --git a/src/main/java/es/iesquevedo/model/GameState.java b/src/main/java/es/iesquevedo/model/GameState.java index 61b8428..a040f94 100644 --- a/src/main/java/es/iesquevedo/model/GameState.java +++ b/src/main/java/es/iesquevedo/model/GameState.java @@ -24,4 +24,3 @@ public enum GameState { */ ABANDONED } - diff --git a/src/main/java/es/iesquevedo/model/Move.java b/src/main/java/es/iesquevedo/model/Move.java new file mode 100644 index 0000000..014b3d7 --- /dev/null +++ b/src/main/java/es/iesquevedo/model/Move.java @@ -0,0 +1,76 @@ +package es.iesquevedo.model; + +import java.util.UUID; + +/** + * Representa un movimiento en una partida de Inazuma Go. + */ +public class Move { + private String id; + private String playerId; + private int row; + private int col; + private boolean isPass; // true si es un pase, false si es colocar piedra + private long timestamp; + private int capturedCount; // número de piedras capturadas en este movimiento + + public Move(String playerId, int row, int col) { + this.id = UUID.randomUUID().toString(); + this.playerId = playerId; + this.row = row; + this.col = col; + this.isPass = false; + this.timestamp = System.currentTimeMillis(); + this.capturedCount = 0; + } + + public Move(String playerId, boolean isPass) { + this.id = UUID.randomUUID().toString(); + this.playerId = playerId; + this.isPass = isPass; + this.timestamp = System.currentTimeMillis(); + this.capturedCount = 0; + this.row = -1; + this.col = -1; + } + + public String getId() { + return id; + } + + public String getPlayerId() { + return playerId; + } + + public int getRow() { + return row; + } + + public int getCol() { + return col; + } + + public boolean isPass() { + return isPass; + } + + public long getTimestamp() { + return timestamp; + } + + public int getCapturedCount() { + return capturedCount; + } + + public void setCapturedCount(int count) { + this.capturedCount = count; + } + + @Override + public String toString() { + if (isPass) { + return "Move{" + "playerId='" + playerId + '\'' + ", PASS" + ", timestamp=" + timestamp + '}'; + } + return "Move{" + "playerId='" + playerId + '\'' + ", row=" + row + ", col=" + col + ", timestamp=" + timestamp + '}'; + } +} diff --git a/src/main/java/es/iesquevedo/model/Player.java b/src/main/java/es/iesquevedo/model/Player.java index a9f6ebc..21b89a3 100644 --- a/src/main/java/es/iesquevedo/model/Player.java +++ b/src/main/java/es/iesquevedo/model/Player.java @@ -76,4 +76,3 @@ public String toString() { '}'; } } - diff --git a/src/main/java/es/iesquevedo/repository/MainRepository.java b/src/main/java/es/iesquevedo/repository/MainRepository.java index e72b521..bcdfc97 100644 --- a/src/main/java/es/iesquevedo/repository/MainRepository.java +++ b/src/main/java/es/iesquevedo/repository/MainRepository.java @@ -108,4 +108,3 @@ default CompletableFuture deleteGame(String gameId) { */ String findDefaultName(); } - diff --git a/src/main/java/es/iesquevedo/repository/firebase/FirebaseHttpClient.java b/src/main/java/es/iesquevedo/repository/firebase/FirebaseHttpClient.java index 316b432..2cc8176 100644 --- a/src/main/java/es/iesquevedo/repository/firebase/FirebaseHttpClient.java +++ b/src/main/java/es/iesquevedo/repository/firebase/FirebaseHttpClient.java @@ -2,34 +2,71 @@ import es.iesquevedo.service.auth.AuthService; +import java.io.BufferedReader; import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; /** * Cliente HTTP minimo para llamadas a Firebase RTDB. */ public class FirebaseHttpClient { private final String baseUrl; - private final HttpClient httpClient; private final AuthService authService; - public FirebaseHttpClient(String baseUrl, HttpClient httpClient, AuthService authService) { + public FirebaseHttpClient(String baseUrl, AuthService authService) { this.baseUrl = baseUrl; - this.httpClient = httpClient; this.authService = authService; } - public HttpResponse get(String path) throws IOException, InterruptedException { - HttpRequest.Builder builder = HttpRequest.newBuilder() - .uri(URI.create(baseUrl + path)) - .GET(); + public HttpResponse get(String path) throws IOException { + URL url = new URL(baseUrl + path); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); - authService.getToken().ifPresent(token -> builder.header("Authorization", "Bearer " + token)); + if (authService.getToken().isPresent()) { + connection.setRequestProperty("Authorization", "Bearer " + authService.getToken().get()); + } - return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + int statusCode = connection.getResponseCode(); + BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream())); + StringBuilder response = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + reader.close(); + connection.disconnect(); + + return new HttpResponse<>(response.toString(), statusCode); + } + + /** + * Clase simple para encapsular respuesta HTTP + */ + public static class HttpResponse { + private final T body; + private final int statusCode; + + public HttpResponse(T body) { + this.body = body; + this.statusCode = 200; // Default + } + + public HttpResponse(T body, int statusCode) { + this.body = body; + this.statusCode = statusCode; + } + + public T body() { + return body; + } + + public int statusCode() { + return statusCode; + } } } diff --git a/src/main/java/es/iesquevedo/repository/firebase/FirebaseMainRepository.java b/src/main/java/es/iesquevedo/repository/firebase/FirebaseMainRepository.java index a801bef..0d698f8 100644 --- a/src/main/java/es/iesquevedo/repository/firebase/FirebaseMainRepository.java +++ b/src/main/java/es/iesquevedo/repository/firebase/FirebaseMainRepository.java @@ -1,129 +1,332 @@ package es.iesquevedo.repository.firebase; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import es.iesquevedo.dto.GameDto; import es.iesquevedo.dto.MoveData; import es.iesquevedo.dto.MoveDto; import es.iesquevedo.repository.MainRepository; - -import com.google.firebase.database.DatabaseReference; -import com.google.firebase.database.DataSnapshot; -import com.google.firebase.database.DatabaseError; -import com.google.firebase.database.FirebaseDatabase; -import com.google.firebase.database.GenericTypeIndicator; -import com.google.firebase.database.ValueEventListener; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; import java.io.IOException; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; +import java.util.logging.Level; +import java.util.logging.Logger; +/** + * Repositorio para Firebase Realtime Database con cliente HTTP (OkHttp). + * Soporta CRUD básico, escritura multi-path (PATCH) y listeners simulados. + */ public class FirebaseMainRepository implements MainRepository { - private final FirebaseDatabase database; - @SuppressWarnings({"FieldCanBeLocal", "CollectionWithoutInitialCapacity", "MismatchedQueryAndUpdateOfCollection"}) - private final Map listeners = new HashMap<>(); + private static final Logger LOGGER = Logger.getLogger(FirebaseMainRepository.class.getName()); + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + private static final String SUFFIX = ".json"; + + private final String firebaseUrl; + private final OkHttpClient httpClient; + private final Gson gson; + private final Map>> movesListeners; + private final int timeoutSeconds; + private String idToken; // Token de autenticación + /** + * Constructor con URL de Firebase (sin .firebaseio.com, se añade automáticamente). + */ public FirebaseMainRepository(String firebaseUrl) { - this.database = FirebaseDatabase.getInstance(firebaseUrl); + this(firebaseUrl, 30); + } + + /** + * Constructor con URL y timeout configurables. + */ + public FirebaseMainRepository(String firebaseUrl, int timeoutSeconds) { + this.firebaseUrl = normalizeUrl(firebaseUrl); + this.timeoutSeconds = timeoutSeconds; + this.gson = new Gson(); + this.movesListeners = new ConcurrentHashMap<>(); + this.httpClient = createHttpClient(); + this.idToken = null; // Se establece después de autenticar + } + + /** + * Normaliza la URL de Firebase (garantiza formato correcto). + */ + private String normalizeUrl(String url) { + if (url == null || url.isBlank()) { + return "https://localhost:9000"; // Fallback para tests con WireMock + } + url = url.trim(); + if (!url.startsWith("http://") && !url.startsWith("https://")) { + url = "https://" + url; + } + if (!url.endsWith(".firebaseio.com") && !url.endsWith(".com")) { + url = url + ".firebaseio.com"; + } + return url; } - /** Constructor para tests: permite inyectar un FirebaseDatabase mockeado. */ - public FirebaseMainRepository(FirebaseDatabase database) { - this.database = database; + /** + * Crea cliente HTTP con timeout y configuración. + */ + private OkHttpClient createHttpClient() { + return new OkHttpClient.Builder() + .connectTimeout(java.time.Duration.ofSeconds(timeoutSeconds)) + .readTimeout(java.time.Duration.ofSeconds(timeoutSeconds)) + .writeTimeout(java.time.Duration.ofSeconds(timeoutSeconds)) + .build(); + } + + /** + * Establece el token de autenticación para futuras peticiones. + */ + public void setIdToken(String idToken) { + this.idToken = idToken; + } + + /** + * Obtiene el token de autenticación actual. + */ + public String getCurrentToken() { + return this.idToken; } @Override - public CompletableFuture getGame(String gameId) { - DatabaseReference ref = database.getReference("games/" + gameId); - CompletableFuture future = new CompletableFuture<>(); - ref.addListenerForSingleValueEvent(new ValueEventListener() { - @Override - public void onDataChange(DataSnapshot snapshot) { - GameDto game = snapshot.getValue(GameDto.class); - future.complete(game); - } + public CompletableFuture createGame(GameDto game) { + return CompletableFuture.supplyAsync(() -> { + try { + String url = firebaseUrl + "/games/" + game.getId() + SUFFIX; + if (idToken != null) { + url += "?auth=" + idToken; + } + String json = gson.toJson(game); + RequestBody body = RequestBody.create(json, JSON); + Request request = new Request.Builder() + .url(url) + .put(body) + .build(); - @Override - public void onCancelled(DatabaseError error) { - future.completeExceptionally(new Exception(error.getMessage())); + try (Response response = httpClient.newCall(request).execute()) { + if (response.isSuccessful() && response.body() != null) { + GameDto created = gson.fromJson(response.body().string(), GameDto.class); + LOGGER.log(Level.INFO, "Game creado: " + game.getId()); + return created; + } else { + LOGGER.log(Level.WARNING, "Error al crear game: " + response.code()); + throw new IOException("HTTP " + response.code()); + } + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Error en createGame: " + e.getMessage()); + throw new RuntimeException(e); } }); - return future; } @Override - public CompletableFuture writeMoveMultiPath(String gameId, MoveDto payload) { - DatabaseReference ref = database.getReference("games/" + gameId); - Map updates = new HashMap<>(); - updates.put("moves", payload.getMoves()); - updates.put("timestamp", payload.getTimestamp()); - updates.put("gameVersion", payload.getGameVersion()); - CompletableFuture future = new CompletableFuture<>(); - ref.updateChildren(updates, (error, ref1) -> { - if (error == null) { - future.complete(null); - } else { - future.completeExceptionally(new Exception(error.getMessage())); + public CompletableFuture> listGames() { + return CompletableFuture.supplyAsync(() -> { + try { + String url = firebaseUrl + "/games" + SUFFIX; + if (idToken != null) { + url += "?auth=" + idToken; + } + Request request = new Request.Builder() + .url(url) + .get() + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (response.isSuccessful() && response.body() != null) { + // Firebase devuelve Map o null + String body = response.body().string(); + if ("null".equals(body)) { + return List.of(); + } + Map games = gson.fromJson(body, + new com.google.gson.reflect.TypeToken>() {}.getType()); + return games != null ? List.copyOf(games.values()) : List.of(); + } else { + LOGGER.log(Level.WARNING, "Error al listar games: " + response.code()); + throw new IOException("HTTP " + response.code()); + } + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Error en listGames: " + e.getMessage()); + throw new RuntimeException(e); } }); - return future; } @Override - public String addMovesListener(String gameId, Consumer> listener) { - DatabaseReference ref = database.getReference("games/" + gameId + "/moves"); - ValueEventListener vel = new ValueEventListener() { - @Override - public void onDataChange(DataSnapshot snapshot) { - GenericTypeIndicator> t = new GenericTypeIndicator<>() { - }; - List moves = snapshot.getValue(t); - listener.accept(moves); + public CompletableFuture getGame(String gameId) { + return CompletableFuture.supplyAsync(() -> { + try { + String url = firebaseUrl + "/games/" + gameId + SUFFIX; + if (idToken != null) { + url += "?auth=" + idToken; + } + Request request = new Request.Builder() + .url(url) + .get() + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (response.isSuccessful() && response.body() != null) { + String body = response.body().string(); + if ("null".equals(body)) { + return null; + } + GameDto gameDto = gson.fromJson(body, GameDto.class); + LOGGER.log(Level.INFO, "Game obtenido: " + gameId); + return gameDto; + } else if (response.code() == 404) { + return null; + } else { + LOGGER.log(Level.WARNING, "Error al obtener game: " + response.code()); + throw new IOException("HTTP " + response.code()); + } + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Error en getGame: " + e.getMessage()); + throw new RuntimeException(e); } + }); + } - @Override - public void onCancelled(DatabaseError error) { - // Handle error, perhaps log or notify + @Override + public CompletableFuture updateGame(String gameId, GameDto game) { + return CompletableFuture.supplyAsync(() -> { + try { + String url = firebaseUrl + "/games/" + gameId + SUFFIX; + if (idToken != null) { + url += "?auth=" + idToken; + } + String json = gson.toJson(game); + RequestBody body = RequestBody.create(json, JSON); + Request request = new Request.Builder() + .url(url) + .patch(body) + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (response.isSuccessful() && response.body() != null) { + GameDto updated = gson.fromJson(response.body().string(), GameDto.class); + LOGGER.log(Level.INFO, "Game actualizado: " + gameId); + return updated; + } else { + LOGGER.log(Level.WARNING, "Error al actualizar game: " + response.code()); + throw new IOException("HTTP " + response.code()); + } + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Error en updateGame: " + e.getMessage()); + throw new RuntimeException(e); } - }; - ref.addValueEventListener(vel); - String id = "listener-" + gameId + "-" + System.currentTimeMillis(); - listeners.put(id, vel); - return id; + }); } @Override - public String findDefaultName() { - DatabaseReference ref = database.getReference("config/defaultName"); - final String[] result = new String[1]; - CountDownLatch latch = new CountDownLatch(1); - ref.addListenerForSingleValueEvent(new ValueEventListener() { - @Override - public void onDataChange(DataSnapshot snapshot) { - result[0] = snapshot.getValue(String.class); - latch.countDown(); + public CompletableFuture deleteGame(String gameId) { + return CompletableFuture.runAsync(() -> { + try { + String url = firebaseUrl + "/games/" + gameId + SUFFIX; + if (idToken != null) { + url += "?auth=" + idToken; + } + Request request = new Request.Builder() + .url(url) + .delete() + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + LOGGER.log(Level.WARNING, "Error al eliminar game: " + response.code()); + throw new IOException("HTTP " + response.code()); + } + LOGGER.log(Level.INFO, "Game eliminado: " + gameId); + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Error en deleteGame: " + e.getMessage()); + throw new RuntimeException(e); } + }); + } + + @Override + public CompletableFuture writeMoveMultiPath(String gameId, MoveDto payload) { + return CompletableFuture.runAsync(() -> { + try { + // Construir URL para PATCH multi-path + String url = firebaseUrl + "/games/" + gameId + SUFFIX; + if (idToken != null) { + url += "?auth=" + idToken; + } + + // Crear payload con estructura PATCH + Map updates = new HashMap<>(); + updates.put("moves", payload.getMoves()); + updates.put("timestamp", payload.getTimestamp()); + updates.put("gameVersion", payload.getGameVersion()); + + String json = gson.toJson(updates); + RequestBody body = RequestBody.create(json, JSON); + Request request = new Request.Builder() + .url(url) + .patch(body) + .build(); - @Override - public void onCancelled(DatabaseError error) { - result[0] = "DefaultPlayer"; - latch.countDown(); + try (Response response = httpClient.newCall(request).execute()) { + if (response.code() == 403) { + throw new IOException("Movimiento rechazado por reglas del servidor (403)"); + } + if (!response.isSuccessful()) { + LOGGER.log(Level.WARNING, "Error al escribir movimientos: " + response.code()); + throw new IOException("HTTP " + response.code()); + } + LOGGER.log(Level.INFO, "Movimientos guardados para partida: " + gameId); + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Error en writeMoveMultiPath: " + e.getMessage()); + throw new RuntimeException(e); } }); - try { - latch.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return "DefaultPlayer"; - } - return result[0] != null ? result[0] : "DefaultPlayer"; } - // Método adicional para pruebas con WireMock (no está en la interfaz) - public boolean patchMultiPath(String path, Map updates) throws IOException { - // TODO: Implementar llamada HTTP real con OkHttp - return true; + @Override + public String addMovesListener(String gameId, Consumer> listener) { + // Simulación básica: guardar listener en mapa para tests + // En producción, implementar SSE (Server-Sent Events) o WebSocket + String listenerId = "listener-" + UUID.randomUUID(); + movesListeners.put(listenerId, listener); + LOGGER.log(Level.INFO, "Listener añadido para partida: " + gameId); + return listenerId; + } + + public void removeMovesListener(String gameId, String listenerId) { + movesListeners.remove(listenerId); + LOGGER.log(Level.INFO, "Listener removido: " + listenerId); + } + + @Override + public String findDefaultName() { + return "FirebasePlayer"; + } + + /** + * Método auxiliar para notificar a listeners (uso interno en tests). + */ + protected void notifyMovesListeners(List moves) { + movesListeners.values().forEach(listener -> listener.accept(moves)); } -} +} \ No newline at end of file diff --git a/src/main/java/es/iesquevedo/repository/firebase/GameEventRepository.java b/src/main/java/es/iesquevedo/repository/firebase/GameEventRepository.java index 6277927..3552831 100644 --- a/src/main/java/es/iesquevedo/repository/firebase/GameEventRepository.java +++ b/src/main/java/es/iesquevedo/repository/firebase/GameEventRepository.java @@ -32,6 +32,7 @@ */ public class GameEventRepository { private static final String EVENTS_PATH = "game_events"; + private static final String FIELD_GAME_ID = "gameId"; private final FirebaseDatabase database; private final String baseUrl; @@ -114,7 +115,7 @@ public Query getGameEventsReference(String gameId) { throw new IllegalStateException("getGameEventsReference solo está disponible en modo Firebase SDK"); } return database.getReference(EVENTS_PATH) - .orderByChild("gameId") + .orderByChild(FIELD_GAME_ID) .equalTo(gameId); } @@ -143,10 +144,13 @@ private CompletableFuture recordEventHttp(EventType eventType, String game "Error al grabar evento " + eventType.getValue() + " (HTTP " + response.statusCode() + "): " + response.body() ); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Hilo interrumpido al grabar evento " + eventType.getValue(), e); } catch (RuntimeException e) { throw e; } catch (Exception e) { - throw new RuntimeException(e); + throw new IllegalStateException("Error al grabar evento " + eventType.getValue(), e); } }); } @@ -188,10 +192,13 @@ private CompletableFuture> fetchGameEventsHttp(String gameId) throw new IllegalStateException( "Error al recuperar eventos (HTTP " + response.statusCode() + "): " + response.body() ); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Hilo interrumpido al recuperar eventos", e); } catch (RuntimeException e) { throw e; } catch (Exception e) { - throw new RuntimeException(e); + throw new IllegalStateException("Error al recuperar eventos", e); } }); } @@ -200,7 +207,7 @@ private CompletableFuture> fetchGameEventsFirebase(String gam CompletableFuture> future = new CompletableFuture<>(); try { Query query = database.getReference(EVENTS_PATH) - .orderByChild("gameId") + .orderByChild(FIELD_GAME_ID) .equalTo(gameId); query.addListenerForSingleValueEvent(new ValueEventListener() { @@ -233,7 +240,7 @@ public void onCancelled(DatabaseError error) { private Map buildEventData(EventType eventType, String gameId, Object payload) { Map eventData = new HashMap<>(); eventData.put("type", eventType.getValue()); - eventData.put("gameId", gameId); + eventData.put(FIELD_GAME_ID, gameId); eventData.put("timestamp", System.currentTimeMillis()); eventData.put("payload", payload); return eventData; @@ -241,7 +248,7 @@ private Map buildEventData(EventType eventType, String gameId, O private String normalizeBaseUrl(String value) { if (value == null || value.isBlank()) { - throw new IllegalArgumentException("La base URL no puede ser nula o vacía"); + throw new IllegalArgumentException("La base URL no puede ser nula o vacía "); } return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; } diff --git a/src/main/java/es/iesquevedo/service/AuthService.java b/src/main/java/es/iesquevedo/service/AuthService.java new file mode 100644 index 0000000..5dfe283 --- /dev/null +++ b/src/main/java/es/iesquevedo/service/AuthService.java @@ -0,0 +1,28 @@ +package es.iesquevedo.service; + +import java.util.concurrent.CompletableFuture; + +/** + * Servicio de autenticación para obtener tokens de Firebase. + */ +public interface AuthService { + + /** + * Login con email y contraseña. + * + * @param email correo del usuario + * @param password contraseña + * @return CompletableFuture con token de autenticación + */ + CompletableFuture login(String email, String password); + + /** + * Obtiene el token actual (si existe y no ha expirado). + */ + String getCurrentToken(); + + /** + * Logout y limpia token. + */ + void logout(); +} diff --git a/src/main/java/es/iesquevedo/service/GameService.java b/src/main/java/es/iesquevedo/service/GameService.java index 53692b3..300e072 100644 --- a/src/main/java/es/iesquevedo/service/GameService.java +++ b/src/main/java/es/iesquevedo/service/GameService.java @@ -83,4 +83,3 @@ public interface GameService { */ Game getGame(String gameId); } - diff --git a/src/main/java/es/iesquevedo/service/MoveValidator.java b/src/main/java/es/iesquevedo/service/MoveValidator.java new file mode 100644 index 0000000..b54c7a3 --- /dev/null +++ b/src/main/java/es/iesquevedo/service/MoveValidator.java @@ -0,0 +1,21 @@ +package es.iesquevedo.service; + +import es.iesquevedo.exception.InvalidMoveException; +import es.iesquevedo.exception.PlayerNotInTurnException; +import es.iesquevedo.model.Game; +import es.iesquevedo.model.Move; + +/** + * Interfaz para validar movimientos en una partida de Inazuma Go. + */ +public interface MoveValidator { + /** + * Valida un movimiento en el contexto de una partida. + * + * @param game La partida actual + * @param move El movimiento a validar + * @throws InvalidMoveException si el movimiento es inválido + * @throws PlayerNotInTurnException si no es el turno del jugador + */ + void validateMove(Game game, Move move) throws InvalidMoveException, PlayerNotInTurnException; +} diff --git a/src/main/java/es/iesquevedo/service/impl/AuthServiceImpl.java b/src/main/java/es/iesquevedo/service/impl/AuthServiceImpl.java new file mode 100644 index 0000000..260306d --- /dev/null +++ b/src/main/java/es/iesquevedo/service/impl/AuthServiceImpl.java @@ -0,0 +1,50 @@ +package es.iesquevedo.service.impl; + +import es.iesquevedo.service.AuthService; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Implementación mock de AuthService para desarrollo. + * En producción, se usaría Firebase Authentication real. + */ +public class AuthServiceImpl implements AuthService { + private static final Logger LOGGER = Logger.getLogger(AuthServiceImpl.class.getName()); + private String currentToken; + private String currentEmail; + + @Override + public CompletableFuture login(String email, String password) { + return CompletableFuture.supplyAsync(() -> { + // Validaciones mínimas (en prod, verificar contra Firebase Auth) + if (email == null || email.isBlank()) { + throw new IllegalArgumentException("Email no puede estar vacío"); + } + if (password == null || password.length() < 6) { + throw new IllegalArgumentException("Contraseña debe tener al menos 6 caracteres"); + } + + // Generar token mock (en prod sería de Firebase) + this.currentToken = "dev-token-" + UUID.randomUUID(); + this.currentEmail = email; + + LOGGER.log(Level.INFO, "Login exitoso: " + email); + return this.currentToken; + }); + } + + @Override + public String getCurrentToken() { + return currentToken; + } + + @Override + public void logout() { + this.currentToken = null; + this.currentEmail = null; + LOGGER.log(Level.INFO, "Logout realizado"); + } +} diff --git a/src/main/java/es/iesquevedo/service/impl/GameServiceImpl.java b/src/main/java/es/iesquevedo/service/impl/GameServiceImpl.java index 7826364..1d54f16 100644 --- a/src/main/java/es/iesquevedo/service/impl/GameServiceImpl.java +++ b/src/main/java/es/iesquevedo/service/impl/GameServiceImpl.java @@ -2,10 +2,13 @@ import es.iesquevedo.exception.InvalidMoveException; import es.iesquevedo.exception.PlayerNotInTurnException; +import es.iesquevedo.model.Board; import es.iesquevedo.model.Game; import es.iesquevedo.model.GameState; +import es.iesquevedo.model.Move; import es.iesquevedo.model.Player; import es.iesquevedo.service.GameService; +import es.iesquevedo.service.MoveValidator; import java.util.HashMap; import java.util.Map; @@ -18,6 +21,15 @@ public class GameServiceImpl implements GameService { // Almacenamiento en memoria (será reemplazado por repositorio en producción) private final Map games = new HashMap<>(); + private final MoveValidator moveValidator; + + public GameServiceImpl() { + this.moveValidator = new InazumaGoMoveValidator(); + } + + public GameServiceImpl(MoveValidator moveValidator) { + this.moveValidator = moveValidator; + } @Override public Game createGame(String gameName, Player player1) { @@ -65,29 +77,96 @@ public Game executeMove(String gameId, String playerId, Object moveData) { throw new IllegalArgumentException("Partida no encontrada: " + gameId); } - // Validar que es turno del jugador - Player currentPlayer = game.getCurrentPlayer(); - if (currentPlayer == null || !currentPlayer.getId().equals(playerId)) { - throw new PlayerNotInTurnException("No es el turno del jugador: " + playerId); + if (!(moveData instanceof Move)) { + throw new IllegalArgumentException("moveData debe ser instancia de Move"); } - // Validar que el jugador está vivo - if (!currentPlayer.isAlive()) { - throw new InvalidMoveException("El jugador no está vivo"); + Move move = (Move) moveData; + + // Validar movimiento con reglas de Inazuma Go + moveValidator.validateMove(game, move); + + // Guardar estado previo del tablero para detectar repetición + Board previousBoardState = game.getBoard().clone(); + + if (move.isPass()) { + // Registrar pase + game.getMoves().add(move); + game.incrementConsecutivePasses(); + + // Doble pase = fin de partida + if (game.getConsecutivePasses() >= 2) { + game.setState(GameState.FINISHED); + // Determinar ganador por puntuación (simplificado) + determineWinner(game); + return game; + } + } else { + // Ejecutar movimiento: colocar piedra + int color = game.getCurrentPlayerIndex() == 0 ? 1 : 2; // 1=negro, 2=blanco + Board board = game.getBoard(); + board.placeStone(move.getRow(), move.getCol(), color); + + // Capturar grupos enemigos sin libertades + int capturedCount = board.captureGroupsWithoutLiberties(); + move.setCapturedCount(capturedCount); + + // Registrar movimiento + game.getMoves().add(move); + + // Reiniciar contador de pases si hubo captura + if (capturedCount > 0) { + game.resetConsecutivePasses(); + } else { + game.resetConsecutivePasses(); // También reinicia con colocación normal + } + + // Detectar repetición: si el tablero es igual al estado previo, fin de partida + if (game.getLastBoardState() != null && game.getLastBoardState().equals(board)) { + game.setState(GameState.FINISHED); + determineWinner(game); + return game; + } } - // Validar que la partida está en curso - if (game.getState() != GameState.IN_PROGRESS) { - throw new InvalidMoveException("La partida no está en curso"); - } + // Guardar estado actual como "último estado" para próxima comparación + game.setLastBoardState(previousBoardState); - // Aquí iría validación específica de reglas del juego - // Por ahora, aceptamos el movimiento passively - // (MOTOR completa con lógica real según reglamento) + // Cambiar turno + game.nextTurn(); return game; } + /** + * Determina el ganador de acuerdo a la puntuación simplificada. + * (En versión completa, implementar algoritmo de conteo según reglamento) + */ + private void determineWinner(Game game) { + // Versión simplificada: cuenta piedras en el tablero + // TODO: Implementar conteo completo según reglamento (libertades, territorio, komi, etc.) + int blackStones = 0; + int whiteStones = 0; + + Board board = game.getBoard(); + for (int r = 0; r < 9; r++) { + for (int c = 0; c < 9; c++) { + if (board.getCell(r, c) == 1) blackStones++; + else if (board.getCell(r, c) == 2) whiteStones++; + } + } + + // Komi de 5.5 para Blanco + double blackScore = blackStones; + double whiteScore = whiteStones + 5.5; + + if (blackScore > whiteScore) { + game.setWinnerPlayerId(game.getPlayers().get(0).getId()); + } else { + game.setWinnerPlayerId(game.getPlayers().get(1).getId()); + } + } + @Override public Game nextTurn(String gameId) { Game game = getGame(gameId); @@ -137,4 +216,3 @@ public void reset() { games.clear(); } } - diff --git a/src/main/java/es/iesquevedo/service/impl/InazumaGoMoveValidator.java b/src/main/java/es/iesquevedo/service/impl/InazumaGoMoveValidator.java new file mode 100644 index 0000000..6e663b1 --- /dev/null +++ b/src/main/java/es/iesquevedo/service/impl/InazumaGoMoveValidator.java @@ -0,0 +1,78 @@ +package es.iesquevedo.service.impl; + +import es.iesquevedo.exception.InvalidMoveException; +import es.iesquevedo.exception.PlayerNotInTurnException; +import es.iesquevedo.model.Board; +import es.iesquevedo.model.Game; +import es.iesquevedo.model.GameState; +import es.iesquevedo.model.Move; +import es.iesquevedo.model.Player; +import es.iesquevedo.service.MoveValidator; + +/** + * Validador de movimientos para Inazuma Go. + * Implementa las reglas del juego: turno, posición, libertades, suicidio, etc. + */ +public class InazumaGoMoveValidator implements MoveValidator { + + @Override + public void validateMove(Game game, Move move) throws InvalidMoveException, PlayerNotInTurnException { + // Verificar que la partida está en progreso + if (game.getState() != GameState.IN_PROGRESS) { + throw new InvalidMoveException("La partida no está en progreso"); + } + + // Verificar que es el turno del jugador + Player currentPlayer = game.getCurrentPlayer(); + if (currentPlayer == null || !currentPlayer.getId().equals(move.getPlayerId())) { + throw new PlayerNotInTurnException("No es el turno del jugador: " + move.getPlayerId()); + } + + // Verificar que el jugador está vivo + if (!currentPlayer.isAlive()) { + throw new InvalidMoveException("El jugador no está vivo"); + } + + // Si es un pase, es válido + if (move.isPass()) { + return; + } + + // Validar posición + if (move.getRow() < 0 || move.getRow() >= 9 || move.getCol() < 0 || move.getCol() >= 9) { + throw new InvalidMoveException("Posición fuera del tablero: (" + move.getRow() + "," + move.getCol() + ")"); + } + + // La posición debe estar vacía + Board board = game.getBoard(); + if (!board.isEmpty(move.getRow(), move.getCol())) { + throw new InvalidMoveException("La posición ya está ocupada: (" + move.getRow() + "," + move.getCol() + ")"); + } + + // Validar que el movimiento no es suicidio + validateNotSuicide(board, move, game); + } + + /** + * Valida que el movimiento no deja el grupo sin libertades (suicidio). + * Un movimiento es suicidio si: + * - Coloca una piedra que quedaría sin libertades + * - Y no captura piedras enemigas que restauren libertades + */ + private void validateNotSuicide(Board board, Move move, Game game) throws InvalidMoveException { + Board testBoard = board.clone(); + + // Colocar la piedra (color: 1 para jugador 0, 2 para jugador 1) + int color = game.getCurrentPlayerIndex() == 0 ? 1 : 2; + testBoard.placeStone(move.getRow(), move.getCol(), color); + + // Capturar grupos enemigos sin libertades + testBoard.captureGroupsWithoutLiberties(); + + // Si el grupo del jugador sigue sin libertades, es suicidio + if (testBoard.countLibertiesForGroup(move.getRow(), move.getCol()) == 0) { + throw new InvalidMoveException("Movimiento es suicidio: la piedra quedaría sin libertades"); + } + } +} + diff --git a/src/main/java/es/iesquevedo/service/impl/MainServiceImpl.java b/src/main/java/es/iesquevedo/service/impl/MainServiceImpl.java index 2b4f2a7..0a6ae55 100644 --- a/src/main/java/es/iesquevedo/service/impl/MainServiceImpl.java +++ b/src/main/java/es/iesquevedo/service/impl/MainServiceImpl.java @@ -21,7 +21,7 @@ public MainServiceImpl(MainRepository repository) { @Override public String greet() { String name = repository.findDefaultName(); - if (name == null || name.isBlank()) { + if (name == null || name.trim().isEmpty()) { throw new NotFoundException("Default player name not found"); } return name.endsWith("!") ? "Hello, " + name : "Hello, " + name + "!"; diff --git a/src/main/java/es/iesquevedo/ui/MainScreenController.java b/src/main/java/es/iesquevedo/ui/MainScreenController.java new file mode 100644 index 0000000..8779944 --- /dev/null +++ b/src/main/java/es/iesquevedo/ui/MainScreenController.java @@ -0,0 +1,90 @@ +package es.iesquevedo.ui; + +import es.iesquevedo.service.MainService; +import javafx.application.Platform; +import javafx.fxml.FXML; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ListView; +import javafx.scene.control.Alert.AlertType; + +import java.util.Arrays; +import java.util.List; + +public class MainScreenController { + + @FXML + private ListView playersListView; + + @FXML + private Button randomMatchButton; + + private MainService mainService; + + // Called by MainGUI after loading FXML + public void setService(MainService mainService) { + this.mainService = mainService; + // Once service is set, we could load real data from Firebase + // For now, load mock players + loadMockPlayers(); + } + + @FXML + public void initialize() { + // Initialization if needed; actual data loading happens in setService + } + + private void loadMockPlayers() { + // TODO: Replace with real data from Firebase via MainService / PlayerRepository + List mockPlayers = Arrays.asList("Jugador1", "Jugador2", "Jugador3", "Jugador4", "Jugador5"); + playersListView.getItems().setAll(mockPlayers); + } + + @FXML + private void onRandomMatch() { + // Simulate random matchmaking + showInfo("Emparejamiento Aleatorio", "Buscando oponente..."); + // In a real implementation, you would call a matchmaking service and navigate to game screen. + // For demo, we simulate a found match after 1 second. + new Thread(() -> { + try { Thread.sleep(1000); } catch (InterruptedException e) {} + Platform.runLater(() -> { + showInfo("Partida encontrada", "Has sido emparejado con OponenteAleatorio. ¡Comienza la partida!"); + // Here you would load the game board screen + }); + }).start(); + } + + @FXML + private void onRefreshPlayers() { + loadMockPlayers(); + showInfo("Actualizar", "Lista de jugadores actualizada."); + } + + @FXML + private void onChallengeSelected() { + String selected = playersListView.getSelectionModel().getSelectedItem(); + if (selected == null) { + showWarning("Atención", "Debes seleccionar un jugador de la lista."); + } else { + showInfo("Retar", "Has retado a " + selected + ". Esperando respuesta..."); + // Here you would send a challenge via Firebase + } + } + + private void showInfo(String title, String message) { + Alert alert = new Alert(AlertType.INFORMATION); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.showAndWait(); + } + + private void showWarning(String title, String message) { + Alert alert = new Alert(AlertType.WARNING); + alert.setTitle(title); + alert.setHeaderText(null); + alert.setContentText(message); + alert.showAndWait(); + } +} \ No newline at end of file diff --git a/src/main/java/es/iesquevedo/util/EmailUtils.java b/src/main/java/es/iesquevedo/util/EmailUtils.java new file mode 100644 index 0000000..4bb3726 --- /dev/null +++ b/src/main/java/es/iesquevedo/util/EmailUtils.java @@ -0,0 +1,18 @@ +package es.iesquevedo.util; + +public final class EmailUtils { + private EmailUtils() {} + + /** + * Valida el formato básico del email (asegura presencia de '@' y dominio con TLD). + * No pretende ser una validación RFC completa, sino una comprobación práctica. + */ + public static boolean isValidEmail(String email) { + if (email == null) return false; + email = email.trim(); + // Regex simple y efectivo que exige: usuario@dominio.tld (TLD >= 2 caracteres) + String emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"; + return email.matches(emailRegex); + } +} + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a79588d..bcad1f4 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,58 +1,16 @@ # Aplicacin: InazumaGoPrevio -# Propiedades de ejemplo +# Propiedades de configuracin + +# App app.name=InazumaGoPrevio app.environment=development -# ============================================================================ -# FIREBASE REALTIME DATABASE CONFIGURATION -# ============================================================================ -# URL base del proyecto Firebase RTDB -firebase.rtdb.url=https://your-project.firebaseio.com - -# Timeouts de conexin (en milisegundos) -firebase.rtdb.timeout.connect=10000 -firebase.rtdb.timeout.read=30000 -firebase.rtdb.timeout.write=30000 - -# Configuracin de reintentos HTTP -firebase.http.retry.max-attempts=3 -firebase.http.retry.backoff-multiplier=2.0 -firebase.http.retry.initial-delay-ms=500 - -# ============================================================================ -# FIREBASE FIRESTORE CONFIGURATION -# ============================================================================ -# Endpoint de Firestore -firebase.firestore.endpoint=https://firestore.googleapis.com/v1 -firebase.firestore.timeout=30000 -firebase.firestore.retry-attempts=3 - -# ============================================================================ -# WIREMOCK CONFIGURATION (Testing) -# ============================================================================ -# Puerto y URL base de WireMock para tests -wiremock.server.port=8080 -wiremock.server.baseurl=http://localhost:8080 - -# ============================================================================ -# GAME EVENTS CONFIGURATION -# ============================================================================ -# Configuracin de eventos de juego para sincronizacin con Firebase -# Tipos de eventos soportados: game.start, game.move, game.end -game.events.enabled=true -game.events.database-path=game_events -game.events.async-processing=true -game.events.executor-threads=2 - -# ============================================================================ -# AUTHENTICATION -# ============================================================================ -# Token de autenticacin (obtenido dinmicamente desde Firebase Auth, no hardcodear) -# firebase.auth.token=${FIREBASE_ID_TOKEN} +# Firebase Configuration +firebase.url=https://inazumago-default-rtdb.firebaseio.com +firebase.timeout.seconds=30 -# ============================================================================ -# NOTA DE SEGURIDAD -# ============================================================================ -# NO guardes claves privadas de Firebase en este archivo. -# Usa variables de entorno o sistemas de gestin de secretos para datos sensibles. +# Authentication +firebase.auth.token=${FIREBASE_AUTH_TOKEN:} +# Logging +logging.level=INFO diff --git a/src/main/resources/fxml/Game.fxml b/src/main/resources/fxml/Game.fxml new file mode 100644 index 0000000..2bcf68a --- /dev/null +++ b/src/main/resources/fxml/Game.fxml @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+ + + + + + + + + +