Skip to content
Public template

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🎮 Hackathon DBP: Tuckersoft Branch Engine

Bandersnatch

Elaborado por: TA José Huamani


El Contexto

Tuckersoft, Londres, 1984.

Stefan Butler está convirtiendo Bandersnatch —una novela con más finales que capítulos— en un videojuego de elección múltiple, pero falta el motor que registra hacia dónde se ramifica cada partida.

Cada vez que un jugador de pruebas decide algo, el sistema tiene que clasificar esa decisión en una de las cinco ramas del guion, recalcular el estado del jugador y moverlo a la escena que toca. Y el analista de QA responsable tiene que enterarse sin que el juego se congele esperando a que salga un correo.

"El problema no es el código. El problema es que las ramas también te miran a ti." — Colin Ritman

Tu misión: construir el Tuckersoft Branch Engine. Los analistas se registran, entran con su cuenta, catalogan las escenas, abren partidas de prueba, envían lo que el jugador decidió y reciben el Informe de Realidad por correo.

Mohan Thakur quiere el juego en las tiendas para Navidad, y Tuckersoft califica sus juegos de 0 a 5 estrellas. Tú decides cuántas se lleva el tuyo.

No hay frontend. Los jugadores de 1984 no usan navegadores.


Duración y Formato

  • Tiempo: 120 minutos exactos
  • Equipos: Grupos de 3 estudiantes
  • Recursos: Uso de IA permitido, documentación e Internet
  • Evaluación: la batería autotests que viene en este repositorio + revisión de código por el TA
  • Condición de aprobación: 5 estrellas de 5. Con 4 o menos, el juego no se publica.

🚦 Arranque: los primeros 10 minutos

Sigue estos pasos en orden. No te saltes ninguno.

1. Rellena equipo.json

Está en la raíz del repositorio. Es lo primero que haces: ese nombre y esos tres integrantes son los que aparecen en el tablero del auditorio.

{
  "equipo": "G07",
  "integrantes": [
    { "nombre": "Ada Lovelace", "codigo": "202010101" },
    { "nombre": "Alan Turing",  "codigo": "202010102" },
    { "nombre": "Grace Hopper", "codigo": "202010103" }
  ]
}

2. Levanta PostgreSQL

docker run --name bandersnatch-db \
  -e POSTGRES_DB=bandersnatch \
  -e POSTGRES_USER=tuckersoft \
  -e POSTGRES_PASSWORD=colin1984 \
  -p 5432:5432 \
  -d postgres:16

Comprueba que quedó arriba con docker ps. Si el puerto 5432 ya está ocupado, borra el contenedor anterior con docker rm -f bandersnatch-db.

¿No tienes Docker? Usa un PostgreSQL local: crea la base bandersnatch, el usuario tuckersoft con contraseña colin1984, y sigue adelante. El enunciado no exige Docker, exige que los datos persistan en PostgreSQL. H2 no está permitido.

3. Crea tu proyecto Spring Boot en la raíz de este repositorio

Créalo como quieras — desde tu IDE, desde start.spring.io o a mano — pero con estas coordenadas y en la raíz, junto a autotests/:

Campo Valor
Tipo Maven
Group com.tuckersoft
Artifact branch-engine
Package com.tuckersoft.branchengine
Java 21

Al terminar, en la raíz tienes que ver pom.xml, mvnw, mvnw.cmd, .mvn/ y src/.

4. Reemplaza el pom.xml por este

Cópialo entero encima del que te generó el asistente. Trae las dependencias del curso más dos que Spring Initializr no ofrece.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.4</version>
    <relativePath/>
  </parent>

  <groupId>com.tuckersoft</groupId>
  <artifactId>branch-engine</artifactId>
  <version>1.0.0</version>
  <name>branch-engine</name>

  <properties>
    <java.version>21</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-mail</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <dependency>
      <groupId>org.postgresql</groupId>
      <artifactId>postgresql</artifactId>
      <scope>runtime</scope>
    </dependency>

    <!-- Firma y validacion de los JWT -->
    <dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt-api</artifactId>
      <version>0.12.6</version>
    </dependency>
    <dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt-impl</artifactId>
      <version>0.12.6</version>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt-jackson</artifactId>
      <version>0.12.6</version>
      <scope>runtime</scope>
    </dependency>

    <!-- Lee el archivo .env y lo expone como propiedades de Spring -->
    <dependency>
      <groupId>me.paulschwarz</groupId>
      <artifactId>spring-dotenv</artifactId>
      <version>4.0.0</version>
    </dependency>

    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <optional>true</optional>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
        <configuration>
          <excludes>
            <exclude>
              <groupId>org.projectlombok</groupId>
              <artifactId>lombok</artifactId>
            </exclude>
          </excludes>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

Dos avisos que te van a ahorrar media hora:

  • La versión de Spring Boot es 3.3.4 y no se toca. start.spring.io genera Spring Boot 4, que no es compatible con este enunciado: por eso el pom.xml viene dado.
  • spring-dotenv no es opcional: es la que lee tu .env. Sin ella la aplicación no arranca.

5. Crea tu .env en la raíz

Crea el archivo .env en la raíz, junto al pom.xml:

DB_HOST=localhost
DB_PORT=5432
DB_NAME=bandersnatch
DB_USERNAME=tuckersoft
DB_PASSWORD=colin1984

JWT_SECRET=cambia-esto-por-una-cadena-larga-de-al-menos-32-caracteres
JWT_EXPIRATION_MS=7200000

MAIL_HOST=localhost
MAIL_PORT=2525
MAIL_USERNAME=qa@tuckersoft.test
MAIL_PASSWORD=qa

ADMIN_NAME=Colin Ritman
ADMIN_EMAIL=colin@tuckersoft.co.uk
ADMIN_PASSWORD=colin1984

JWT_SECRET de al menos 32 caracteres, o la aplicación no arranca (WeakKeyException).

ADMIN_EMAIL y ADMIN_PASSWORD no los cambies. Los autotests entran con esas credenciales exactas para probar los permisos de administrador.

El correo va a un servidor SMTP de pruebas que levantan los autotests en localhost:2525; el usuario y la contraseña dan igual. Aun así tienes que enviar de verdad con JavaMailSender: si solo guardas la fila en RealityLog, el correo no llega y la ★5 falla.

El .env ya está en el .gitignore. Nunca lo subas.

6. Pega esto en src/main/resources/application.properties

server.port=8080

spring.datasource.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:bandersnatch}
spring.datasource.username=${DB_USERNAME:tuckersoft}
spring.datasource.password=${DB_PASSWORD:colin1984}
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false

jwt.secret=${JWT_SECRET}
jwt.expiration-ms=${JWT_EXPIRATION_MS:7200000}

spring.mail.host=${MAIL_HOST:localhost}
spring.mail.port=${MAIL_PORT:2525}
spring.mail.username=${MAIL_USERNAME:}
spring.mail.password=${MAIL_PASSWORD:}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true

app.admin.display-name=${ADMIN_NAME}
app.admin.email=${ADMIN_EMAIL}
app.admin.password=${ADMIN_PASSWORD}

No cambies mail.smtp.auth ni mail.smtp.starttls.enable.

Las variables de correo tienen valor por defecto: la app arranca aunque tu .env esté a medias. El correo solo hace falta para la ★5.

7. Arranca y comprueba

./mvnw spring-boot:run

Con eso ya puedes correr los autotests por primera vez. Van a fallar todos, y está bien: te dicen exactamente qué falta.


✅ Cómo sabes en qué estrella vas

No levantes la mano para preguntar si un checkpoint está bien. Este repositorio trae la misma batería de pruebas que usará el TA, en la carpeta autotests/.

Con tu aplicación corriendo en otra terminal, desde la raíz del repositorio:

cd autotests
./mvnw test

En Windows, desde la raíz:

cd autotests
mvnw.cmd test

Al terminar imprime un resumen así:

  ──────────────────────────────────────────────
   TUCKERSOFT · CONTROL DE CALIDAD
   ★★★☆☆   3 / 5
  ──────────────────────────────────────────────
   ✔  ★1  SEGURIDAD          18/18
   ✔  ★2  NODOS              14/14
   ✔  ★3  PARTIDAS           16/16
   ✘  ★4  DECISIONES         11/15
        └ El nodo destino sale de la rama que corresponde
          esperaba: NODE-ESPEJO
          recibido: NODE-BUS
          pista: RUPTURA_CUARTA_PARED o impacto CRITICO usan
                 glitchBranchCode; el resto usa primaryBranchCode.
   ·  ★5  ASINCRONIA         no evaluado
  ──────────────────────────────────────────────

Reglas:

  1. Córrelo las veces que quieras: cada corrida usa sus propios datos y no choca con los tuyos.
  2. Las estrellas van en orden: si la ★2 falla, las siguientes salen como no evaluado.
  3. Nunca toques la carpeta autotests/: modificarla anula la hackathon.
  4. Cada corrida publica tus estrellas en el tablero del auditorio si equipo.json está completo. Si el tablero falla, tus estrellas no cambian.
  5. Si el comando falla, lee autotests/README.md.

Requerimientos Técnicos

  • Java 21
  • Spring Boot 3.3.4 (la fija el pom.xml de arriba)
  • Spring Web · Spring Data JPA · Validation
  • Spring Security + JWT (jjwt)
  • Spring Boot Mail (JavaMail)
  • PostgreSQL
  • @Async + @TransactionalEventListener
  • JUnit 5 + Mockito

🏗️ Modelo de Datos (Obligatorio)

Cinco entidades. El TA las verificará directamente en PostgreSQL.

User        1 ──────── N Playthrough
StoryNode   1 ──────── N Playthrough   (nodo actual)
StoryNode   1 ──────── N Decision      (nodo de origen)
Playthrough 1 ──────── N Decision
Decision    1 ──────── N RealityLog

Las relaciones son bidireccionales (@OneToMany + @ManyToOne). Si serializas la entidad JPA directamente en el response, Jackson entra en bucle infinito. Usa siempre DTOs y nunca devuelvas la entidad desde el Controller.


Entidad User

El analista de QA. Se registra, entra con su cuenta y es dueño de sus partidas. El correo con el que se registra es el mismo al que llega el Informe de Realidad.

Campo Tipo Descripción
id Long (PK) Generado automáticamente.
email String (único) Obligatorio, formato email. Es el usuario de login. Repetido → 409.
password String Obligatorio, mínimo 6 caracteres. Se guarda con BCrypt, nunca en texto plano.
displayName String Obligatorio, 3–60 caracteres.
role String ROLE_USER o ROLE_ADMIN. Quien se registra siempre queda como ROLE_USER.
createdAt Instant Asignado en el service antes de guardar.

El administrador se crea al arrancar con un DataInitializer que lee ADMIN_NAME, ADMIN_EMAIL y ADMIN_PASSWORD, codifica la contraseña con BCrypt y le pone ROLE_ADMIN. Si ya existe un usuario con ese email, no hace nada.


Entidad StoryNode

Una escena de Bandersnatch. Cada nodo declara hacia dónde continúa la historia.

Campo Tipo Descripción
id Long (PK) Generado automáticamente.
nodeCode String (único) Código de escena. Ej: NODE-CEREAL. Obligatorio, 3–40 caracteres. Repetido → 409.
title String Obligatorio, 3–80 caracteres.
sceneText String (TEXT) Obligatorio, mínimo 10 caracteres.
branchCapacity Integer Obligatorio, mayor a 0. Máximo de partidas que pueden arrancar aquí.
currentBranches Integer Inicia siempre en 0. Sube 1 cuando una partida arranca en este nodo.
primaryBranchCode String Nullable. nodeCode del siguiente nodo por la vía normal.
glitchBranchCode String Nullable. nodeCode del siguiente nodo cuando la realidad se rompe.
createdAt Instant Asignado en el service antes de guardar.

primaryBranchCode y glitchBranchCode son Strings, no llaves foráneas. Los nodos se crean en cualquier orden y pueden apuntar a nodos que todavía no existen. La resolución ocurre al decidir, no al crear.

Regla de negocio: no se puede arrancar una partida en un nodo lleno (currentBranches >= branchCapacity) → 400.


Entidad Playthrough

Una partida de prueba. Pertenece al usuario que la creó.

Campo Tipo Descripción
id Long (PK) Generado automáticamente.
playerTag String (único) Obligatorio, 2–40 caracteres. Repetido → 409.
user FK → User @ManyToOne. El dueño. Sale del token, no del request.
startNodeCode String nodeCode donde arrancó. No cambia nunca.
currentNode FK → StoryNode @ManyToOne. Se actualiza en cada decisión que mueve la historia.
lucidity Integer Rango 0–100. Inicia en 100.
controlLevel Integer Rango 0–100. Inicia en 0.
status String ACTIVA o FINALIZADA. Inicia en ACTIVA.
endingCode String Nullable. Solo se asigna cuando status = FINALIZADA.
createdAt Instant Asignado en el service.
updatedAt Instant Actualizado cada vez que cambian sus stats.

Pasos del service al crear un Playthrough:

  1. Tomar el usuario autenticado del contexto de seguridad.
  2. Buscar StoryNode por startNodeCode. Si no existe → 404.
  3. Si el playerTag ya existe → 409.
  4. Si node.currentBranches >= node.branchCapacity → 400.
  5. Crear la partida con lucidity=100, controlLevel=0, status=ACTIVA, endingCode=null y startNodeCode = currentNode.nodeCode.
  6. Incrementar node.currentBranches en 1 y guardar el nodo.
  7. Guardar la partida.

Entidad Decision

Lo que el jugador decidió hacer y hacia dónde lo mandó el motor.

Campo Tipo Descripción
id Long (PK) Generado automáticamente.
playthrough FK → Playthrough @ManyToOne. Obligatorio.
node FK → StoryNode @ManyToOne. Nodo de origen: el currentNode al momento de decidir.
rawInput String (TEXT) Lo que el jugador escribió. Obligatorio, mínimo 10 caracteres.
branchType String Rama clasificada. Ver reglas abajo.
impactLevel String LEVE, MODERADO, GRAVE o CRITICO. Viene en el request.
handlerUnit String Departamento. Se deriva del branchType.
outcomeCode String Código de consecuencia. Se deriva del branchType.
resolvedNodeCode String Nullable. nodeCode al que resolvió la rama.
status String REGISTRADA, PROCESANDO, ESTABILIZADA o ERROR.
createdAt Instant Asignado en el service.
updatedAt Instant Actualizado cada vez que cambia el status.

Entidad RealityLog

Audita cada intento de envío del Informe de Realidad.

Campo Tipo Descripción
id Long (PK) Generado automáticamente.
decision FK → Decision @ManyToOne. Obligatorio.
recipientEmail String Email al que se intentó enviar.
subject String Asunto del correo.
logStatus String SENT o FAILED.
errorMessage String (TEXT) Nullable. Mensaje de error si falla el SMTP.
sentAt Instant Nullable. Solo se asigna cuando el envío es exitoso.
createdAt Instant Asignado en el service.

🔐 Seguridad

La API está cerrada. Sin token no se entra a ningún endpoint de negocio.

Reglas de acceso

Endpoint Quién puede
POST /api/v1/auth/register Cualquiera, sin token
POST /api/v1/auth/login Cualquiera, sin token
POST /api/v1/nodes Solo ROLE_ADMIN
GET /api/v1/users Solo ROLE_ADMIN
PATCH /api/v1/users/{id}/role Solo ROLE_ADMIN
Todo lo demás bajo /api/v1/** Cualquier usuario autenticado

Propiedad y supervisión

Hay dos niveles, y no son lo mismo:

ROLE_USER ROLE_ADMIN
Ver sus partidas y decisiones sí sí
Ver partidas y decisiones de otros no → 403 sí, es supervisor
GET /api/v1/playthroughs devuelve solo las suyas devuelve todas
Decidir sobre una partida ajena no → 403 tampoco → 403
Crear nodos, listar usuarios, cambiar roles no → 403 sí

El administrador supervisa, no juega: puede leer cualquier partida, pero decidir sobre una ajena le da 403 como a cualquiera. El permiso extra de admin va en las lecturas, no en las escrituras.

El rol se lee de la base de datos, no del token

El JWT lleva solo la identidad del usuario (su email). Las autoridades se cargan desde la base de datos en cada petición, a través del UserDetailsService.

Así, si un administrador promueve a alguien, su token actual ya tiene los permisos nuevos sin volver a iniciar sesión. Si guardas el rol dentro del token, el checkpoint falla.

Configuración requerida

  • SecurityFilterChain con csrf deshabilitado y SessionCreationPolicy.STATELESS.
  • PasswordEncoder como bean BCryptPasswordEncoder. La contraseña nunca se guarda ni se devuelve en texto plano.
  • Un OncePerRequestFilter que lea Authorization: Bearer <token>, valide la firma y coloque la autenticación en el SecurityContextHolder.
  • Un UserDetailsService que busque al usuario por email.
  • Un AuthenticationEntryPoint y un AccessDeniedHandler que escriban el formato de error del enunciado en los 401 y 403. Spring Security los devuelve con cuerpo vacío por defecto: hay que registrarlos a mano.

Registro

POST /api/v1/auth/register

{
  "email": "equipo.real@gmail.com",
  "password": "bandersnatch84",
  "displayName": "Ada Lovelace"
}

Response 201:

{
  "token": "eyJhbGciOiJIUzI1NiJ9...",
  "type": "Bearer",
  "email": "equipo.real@gmail.com",
  "displayName": "Ada Lovelace",
  "role": "ROLE_USER"
}
  • Email repetido → 409
  • Email inválido, displayName de menos de 3 caracteres o contraseña de menos de 6 → 400
  • El role siempre es ROLE_USER. Si el JSON trae un campo role, se ignora.

Login

POST /api/v1/auth/login

{ "email": "equipo.real@gmail.com", "password": "bandersnatch84" }

Response 200: el mismo cuerpo que el registro. Credenciales incorrectas → 401, sin revelar si falló el email o la contraseña.

Usuario actual y listado

GET /api/v1/users/me (200) — cualquier autenticado:

{
  "id": 2,
  "email": "equipo.real@gmail.com",
  "displayName": "Ada Lovelace",
  "role": "ROLE_USER",
  "createdAt": "2026-09-24T09:00:00Z"
}

GET /api/v1/users (200) — solo ROLE_ADMIN: un array con ese mismo DTO por cada usuario.

PATCH /api/v1/users/{id}/role (200) — solo ROLE_ADMIN:

{ "role": "ROLE_ADMIN" }

Devuelve el usuario actualizado, con el mismo DTO de arriba.

Caso Respuesta
Lo pide un ROLE_USER 403
El id no existe 404
El role no es ROLE_USER ni ROLE_ADMIN 400
Un administrador intenta cambiar su propio rol 400 — nadie se degrada a sí mismo y se queda sin administradores

Ningún response del sistema devuelve nunca el campo password, ni siquiera codificado.

Usar el token

Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
  • Sin cabecera, token inválido o vencido → 401
  • Token válido pero sin permiso → 403

🌿 Clasificación de Decisiones

Aquí no hay IA ni servicios externos. El motor clasifica con reglas propias, y por eso el resultado es siempre el mismo para la misma entrada.

Paso 1 — Normalizar el texto

Antes de comparar nada, el rawInput se pasa a minúsculas y se le quitan las tildes:

String texto = java.text.Normalizer
        .normalize(rawInput, java.text.Normalizer.Form.NFD)
        .replaceAll("\\p{M}", "")
        .toLowerCase();

Así "CÁMARA", "cámara" y "camara" se comparan todos igual.

Paso 2 — Aplicar las reglas en este orden

La primera regla que se cumple gana; las demás no se evalúan.

# Regla sobre el texto normalizado branchType
1 No contiene ninguna letra de la a a la z ENTRADA_CORRUPTA
2 Contiene netflix, camara, espectador o videojuego RUPTURA_CUARTA_PARED
3 Contiene vigilan, simbolo o conspiracion SOSPECHA
4 Contiene rechaza, destruye, desobedece o renuncia REBELDIA
5 Cualquier otro caso OBEDIENCIA

El orden importa. "Stefan destruye la camara" es RUPTURA_CUARTA_PARED, no REBELDIA, porque la regla 2 se evalúa antes que la 4.

Paso 3 — Derivar el departamento y la consecuencia

Ni handlerUnit ni outcomeCode vienen en el request: salen de esta tabla.

branchType handlerUnit outcomeCode
OBEDIENCIA Mesa de Guion ADVANCE_MAIN_PATH
REBELDIA Control de Continuidad FORK_TIMELINE
SOSPECHA Oficina de Seguridad INJECT_WHITE_BEAR_SYMBOL
RUPTURA_CUARTA_PARED Departamento Netflix BREAK_FOURTH_WALL
ENTRADA_CORRUPTA Archivo de Errores DISCARD_INPUT

Mesa de Guion va sin tilde, tal cual. Los autotests comparan el String exacto.

Paso 4 — Las entradas corruptas se descartan

Si el branchType resultó ENTRADA_CORRUPTA, la decisión se guarda pero no produce nada más:

Campo Valor
resolvedNodeCode null
status ERROR

Y además:

  • No se toca la partida. lucidity, controlLevel, status y currentNode quedan exactamente como estaban.
  • No se publica el evento, así que no hay correo ni RealityLog.
  • El Controller igualmente devuelve 201.

📊 Reglas de Actualización de la Partida

Solo para decisiones que no son ENTRADA_CORRUPTA. Todo ocurre en la misma transacción que guarda la decisión.

Paso 1 — Stats según el impacto

impactLevel lucidity controlLevel
LEVE −5 +5
MODERADO −15 +10
GRAVE −30 +20
CRITICO −40 +45

Límites estrictos: lucidity y controlLevel van de 0 a 100. Usa Math.max y Math.min.

Paso 2 — Resolver el nodo destino

Del nodo de origen se elige uno de sus dos códigos de rama:

  • Si branchType es RUPTURA_CUARTA_PARED, o impactLevel es CRITICO → glitchBranchCode
  • En cualquier otro caso → primaryBranchCode

Ese valor va a decision.resolvedNodeCode, aunque el nodo no exista.

Paso 3 — Resolver el estado de la partida

Evaluar en este orden exacto, con los valores ya calculados en el Paso 1:

# Condición Resultado
1 controlLevel >= 100 FINALIZADA, endingCode = ENDING_PAC_SYMBOL
2 lucidity <= 0 FINALIZADA, endingCode = ENDING_WHITE_BEAR
3 el código destino es null, o no corresponde a ningún StoryNode FINALIZADA, endingCode = ENDING_NETFLIX_CUT
4 ninguna de las anteriores ACTIVA y currentNode pasa a ser el nodo destino

En los casos 1, 2 y 3 la partida termina y currentNode no cambia. Siempre se actualiza updatedAt.

El orden importa de verdad. Tres decisiones CRITICO seguidas dejan controlLevel = 100 y lucidity = 0 en la misma decisión: si evalúas la lucidez primero, el final sale mal. Los autotests lo comprueban.

Una partida FINALIZADA ya no acepta decisiones → 409.

Orden de operaciones en el service

  1. Tomar el usuario autenticado y validar que la partida le pertenece
  2. Validar el request y que la partida esté ACTIVA
  3. Clasificar el rawInput y derivar handlerUnit y outcomeCode
  4. Si es ENTRADA_CORRUPTA: guardar con status = ERROR, sin tocar la partida ni publicar evento, y retornar 201
  5. Aplicar stats, resolver el nodo destino y definir el estado de la partida
  6. Guardar el Playthrough
  7. Guardar la Decision con status = REGISTRADA
  8. Publicar DecisionCommittedEvent
  9. Retornar 201

🧪 Modo QA

Para poder probar qué pasa cuando el correo falla sin tener que romper tus credenciales, POST /api/v1/decisions acepta una cabecera opcional:

Cabecera Efecto
X-Bandersnatch-Simulate: MAIL_FAILURE El envío SMTP lanza una excepción dentro del listener. Se ejerce la rama FAILED del RealityLog.

Reglas:

  • Sin cabecera, o con otro valor, el flujo es normal. Nunca devuelve 400 por un valor desconocido.
  • El código HTTP no cambia: sigue siendo 201.
  • Pásala al listener como un campo del evento.
  • Tiene que lanzar una excepción real, atrapada por el mismo catch que un fallo de verdad. Nada de escribir el log FAILED a mano.

Funcionalidades Requeridas

Todo lo que no sea /auth/** exige Authorization: Bearer <token>.

1. Nodos de Historia

Método Endpoint Acceso
POST /api/v1/nodes ADMIN
GET /api/v1/nodes autenticado
GET /api/v1/nodes/{id} autenticado

Request POST:

{
  "nodeCode": "NODE-CEREAL",
  "title": "El desayuno",
  "sceneText": "Stefan debe elegir entre Sugar Puffs y Frosties mientras su padre lo observa.",
  "branchCapacity": 1,
  "primaryBranchCode": "NODE-BUS",
  "glitchBranchCode": "NODE-ESPEJO"
}

Response 201:

{
  "id": 1,
  "nodeCode": "NODE-CEREAL",
  "title": "El desayuno",
  "sceneText": "Stefan debe elegir entre Sugar Puffs y Frosties mientras su padre lo observa.",
  "branchCapacity": 1,
  "currentBranches": 0,
  "primaryBranchCode": "NODE-BUS",
  "glitchBranchCode": "NODE-ESPEJO",
  "createdAt": "2026-09-24T09:30:00Z"
}

2. Partidas

Método Endpoint Acceso
POST /api/v1/playthroughs autenticado
GET /api/v1/playthroughs usuario: solo las suyas · admin: todas
GET /api/v1/playthroughs/{id} dueño o admin — 403 para el resto
GET /api/v1/playthroughs/{id}/path dueño o admin — 403 para el resto

Request POST:

{ "playerTag": "STEFAN-01", "startNodeCode": "NODE-CEREAL" }

No hay campo de usuario. El dueño sale del token.

Response 201:

{
  "id": 1,
  "playerTag": "STEFAN-01",
  "ownerEmail": "equipo.real@gmail.com",
  "startNodeCode": "NODE-CEREAL",
  "currentNodeCode": "NODE-CEREAL",
  "lucidity": 100,
  "controlLevel": 0,
  "status": "ACTIVA",
  "endingCode": null,
  "createdAt": "2026-09-24T09:45:00Z",
  "updatedAt": "2026-09-24T09:45:00Z"
}

GET /api/v1/playthroughs devuelve un array simple ordenado por createdAt descendente: las del usuario del token si es ROLE_USER, todas si es ROLE_ADMIN.

Response /path (200): solo las decisiones con resolvedNodeCode no nulo, ordenadas por createdAt ascendente, con order empezando en 1.

{
  "playthroughId": 1,
  "playerTag": "STEFAN-01",
  "status": "ACTIVA",
  "endingCode": null,
  "startNodeCode": "NODE-CEREAL",
  "currentNodeCode": "NODE-BUS",
  "steps": [
    {
      "order": 1,
      "decisionId": 5,
      "fromNodeCode": "NODE-CEREAL",
      "toNodeCode": "NODE-BUS",
      "branchType": "OBEDIENCIA",
      "impactLevel": "LEVE",
      "createdAt": "2026-09-24T10:05:00Z"
    }
  ]
}

3. Decisiones

Método Endpoint Acceso
POST /api/v1/decisions solo el dueño de la partida, admin incluido
GET /api/v1/decisions usuario: las suyas · admin: todas
GET /api/v1/decisions/{id} dueño o admin — 403 para el resto
GET /api/v1/decisions/{id}/reality-logs dueño o admin — 403 para el resto

Request POST:

{
  "playthroughId": 1,
  "rawInput": "Stefan acepta la oferta de Mohan y se queda en Tuckersoft.",
  "impactLevel": "LEVE"
}
  • playthroughId inexistente → 404
  • partida ajena → 403
  • partida FINALIZADA → 409
  • rawInput de menos de 10 caracteres, o impactLevel fuera de la lista → 400

Response 201:

{
  "id": 5,
  "playthroughId": 1,
  "playerTag": "STEFAN-01",
  "sourceNodeCode": "NODE-CEREAL",
  "resolvedNodeCode": "NODE-BUS",
  "rawInput": "Stefan acepta la oferta de Mohan y se queda en Tuckersoft.",
  "branchType": "OBEDIENCIA",
  "impactLevel": "LEVE",
  "handlerUnit": "Mesa de Guion",
  "outcomeCode": "ADVANCE_MAIN_PATH",
  "status": "REGISTRADA",
  "playthroughStatus": "ACTIVA",
  "lucidity": 95,
  "controlLevel": 5,
  "endingCode": null,
  "createdAt": "2026-09-24T10:05:00Z",
  "updatedAt": "2026-09-24T10:05:00Z"
}

playthroughStatus, lucidity, controlLevel y endingCode son el estado de la partida después de aplicar la decisión. No son opcionales: los autotests verifican tus cálculos con ellos.

Query params de GET /api/v1/decisions (las de partidas propias; el administrador ve todas):

Param Tipo Ejemplo
branchType String SOSPECHA
impactLevel String CRITICO
status String ESTABILIZADA
playthroughId Long 1
page Integer 0 (default)
size Integer 10 (default)

Response paginado (200):

{
  "content": [],
  "totalElements": 0,
  "totalPages": 0,
  "currentPage": 0,
  "size": 10
}

Response /reality-logs (200):

[
  {
    "id": 1,
    "decisionId": 5,
    "recipientEmail": "equipo.real@gmail.com",
    "subject": "[TUCKERSOFT] OBEDIENCIA en STEFAN-01 | Impacto LEVE",
    "logStatus": "SENT",
    "errorMessage": null,
    "sentAt": "2026-09-24T10:05:04Z",
    "createdAt": "2026-09-24T10:05:04Z"
  }
]

⚡ Flujo Asíncrono de Notificación

El Controller devuelve 201 de forma inmediata. El correo ocurre en un hilo separado, después de que PostgreSQL confirma la transacción.

POST /api/v1/decisions  (con Authorization: Bearer ...)
        │
        ▼
   [DecisionService]  — @Transactional
   1. Usuario del token; valida que la partida es suya y esta ACTIVA
   2. Clasifica el rawInput y deriva handlerUnit / outcomeCode
   3. Si es ENTRADA_CORRUPTA → guarda con ERROR, no toca nada mas, 201
   4. Aplica stats, resuelve nodo destino y estado de la partida
   5. Guarda Playthrough y Decision (status = REGISTRADA)
   6. Publica DecisionCommittedEvent
        │
        ├────────────────────────────────► Retorna 201 (inmediato)
        │
   PostgreSQL hace COMMIT
        │
        ▼  (solo despues del COMMIT)
   [BranchNotificationListener]  — @Component separado
   @TransactionalEventListener(phase = AFTER_COMMIT) + @Async + @Transactional
        │
   7. decision.status → PROCESANDO
   8. Envia el Informe de Realidad al email del dueño
        ├── Exito → ESTABILIZADA + RealityLog SENT
        └── Fallo → ERROR + RealityLog FAILED + log.error()
   9. Imprime [BRANCH-LOG] en consola

Por qué @TransactionalEventListener y no @EventListener

Con @EventListener el evento se dispara antes del commit, y el listener asíncrono puede buscar una decisión que todavía no existe en la base de datos. Con phase = AFTER_COMMIT solo se activa cuando PostgreSQL ya confirmó. El TA lo revisa.

Configuración Requerida

Una clase @Configuration con @EnableAsync que registre un ThreadPoolTaskExecutor con:

  • corePoolSize: 2 · maxPoolSize: 4 · queueCapacity: 50
  • threadNamePrefix: "branch-worker-"

El listener va en una clase @Component distinta al DecisionService. El DecisionService nunca inyecta JavaMailSender ni referencia al listener.

El listener necesita su propia transacción para que sus cambios se persistan, y tiene que ser REQUIRES_NEW:

@Async("branchExecutor")
@Transactional(propagation = Propagation.REQUIRES_NEW)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void alCommit(DecisionCommittedEvent evento) { ... }

Sin REQUIRES_NEW la aplicación no arranca: Spring rechaza un @Transactional normal sobre un @TransactionalEventListener. El listener corre en otro hilo después del commit y necesita su propia transacción.

Ojo: el listener corre en otro hilo, así que ahí ya no hay usuario autenticado. Pon dentro del evento todo lo que necesite (destinatario, jugador…).

Informe de Realidad (correo)

Se envía con JavaMailSender. Los autotests lo reciben en su servidor SMTP y comprueban las tres cosas: destinatario, asunto y contenido del cuerpo.

  • Destinatario: el email del dueño de la partida
  • Subject: [TUCKERSOFT] <branchType> en <playerTag> | Impacto <impactLevel>
  • Body:
Hola <displayName>,

Una partida de prueba acaba de ramificarse.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Decision ID      : #<id>
Jugador          : <playerTag>
Rama             : <branchType>
Impacto          : <impactLevel>
Departamento     : <handlerUnit>
Consecuencia     : <outcomeCode>
Nodo origen      : <sourceNodeCode>
Nodo destino     : <resolvedNodeCode>
Estado partida   : <playthroughStatus>
Lucidez          : <lucidity>/100
Nivel de control : <controlLevel>/100
Final            : <endingCode>
Registrada       : <createdAt>
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Decisión original del jugador:
"<rawInput>"

— Tuckersoft Branch Engine, 1984

Cuando endingCode es null, imprime -.

Los espacios no tienen que cuadrar, pero todas las etiquetas y valores sí: los autotests buscan Decision ID, Jugador, Rama, Impacto, Departamento, Lucidez y Nivel de control, además del id de la decisión, la etiqueta del jugador y el texto original del jugador.

Log obligatorio en consola

[BRANCH-LOG] Decision ID: <id> | Player: <playerTag> | Branch: <branchType> | Impact: <impactLevel> | Unit: <handlerUnit> | Node: <sourceNodeCode> -> <resolvedNodeCode> | Thread: <nombre_hilo> | Status: <status_final>

El TA verificará que el hilo sea branch-worker-X y no http-nio-8080-exec-X. Si son iguales, @Async no está funcionando.


Manejo de Errores

Todos los errores, incluidos los 401 y 403 de Spring Security, responden con este formato:

{
  "error": "TIPO_ERROR",
  "message": "Descripción clara del problema",
  "timestamp": "2026-09-24T10:00:00Z",
  "path": "/api/v1/decisions"
}
Código Cuándo
200 / 201 Consulta exitosa / recurso creado
400 Validación fallida, o nodo lleno
401 Sin token, token inválido o vencido, credenciales incorrectas
403 Autenticado pero sin permiso: crear nodo sin ser admin, o tocar algo ajeno
404 Recurso no encontrado
409 email, nodeCode o playerTag repetido; partida ya FINALIZADA

⭐ Las Cinco Estrellas

Los autotests evalúan en orden y no avanzan si la estrella anterior falló.

★1 — Seguridad

  1. El DataInitializer crea al admin con la contraseña codificada con BCrypt.
  2. register crea la cuenta y devuelve token; email repetido → 409; datos inválidos → 400.
  3. login devuelve token; contraseña incorrecta → 401.
  4. El role del registro siempre es ROLE_USER, aunque el request pida ROLE_ADMIN.
  5. GET /api/v1/users/me sin token → 401 con el formato de error; con token → 200 sin campo password.
  6. GET /api/v1/users con usuario normal → 403; con admin → 200.
  7. PATCH /api/v1/users/{id}/role con usuario normal → 403; con un rol inválido → 400; sobre uno mismo → 400; con un id inexistente → 404.
  8. Tras promover a un usuario, su token de antes ya sirve para crear nodos: las autoridades se leen de la base de datos, no del token.

★2 — Nodos

  1. POST /api/v1/nodes con token de usuario normal → 403; con admin → 201, currentBranches = 0.
  2. nodeCode repetido → 409; branchCapacity = 0 o campos faltantes → 400.
  3. GET /api/v1/nodes/{id} devuelve el nodo; id inexistente → 404.

★3 — Partidas

  1. POST /api/v1/playthroughs crea con lucidity=100, controlLevel=0, status=ACTIVA, dueño del token.
  2. currentBranches del nodo sube a 1.
  3. Nodo lleno → 400; playerTag repetido → 409; startNodeCode inexistente → 404.
  4. GET /api/v1/playthroughs devuelve solo las del usuario; el administrador las ve todas.
  5. Una partida de otro usuario → 403 en detalle y en /path para un ROLE_USER, y 200 para el administrador.
  6. El administrador no puede decidir sobre una partida ajena: 403.
  7. /path de una partida sin decisiones devuelve steps vacío.

★4 — Decisiones

  1. Las cinco reglas de clasificación dan el branchType correcto, incluida la de precedencia y la del texto con tildes.
  2. handlerUnit y outcomeCode salen de la tabla.
  3. Los stats cambian exactamente según el impacto, con los límites respetados.
  4. El nodo destino sale de primaryBranchCode o glitchBranchCode según la regla, y currentNode se mueve.
  5. ENTRADA_CORRUPTA deja status=ERROR, resolvedNodeCode=null y la partida intacta.
  6. Los tres finales salen en el orden correcto.
  7. Partida FINALIZADA → 409; partida ajena → 403; impactLevel inválido → 400.
  8. Filtros y paginación de GET /api/v1/decisions funcionan.

★5 — Asincronía

  1. POST /api/v1/decisions responde en menos de 1.5 s con status = REGISTRADA.
  2. En menos de 25 s la decisión pasa a ESTABILIZADA.
  3. /reality-logs trae un log SENT con sentAt no nulo y el subject exacto.
  4. El correo llega de verdad al servidor SMTP de pruebas, dirigido al dueño de la partida, con el asunto exacto y con todos los campos del cuerpo.
  5. Con X-Bandersnatch-Simulate: MAIL_FAILURE: 201, log FAILED con errorMessage, decisión en ERROR, y el sistema sigue aceptando decisiones.
  6. /path refleja el recorrido completo, sin las decisiones corruptas.

🧪 Tus Tests Unitarios (Obligatorio)

Además de los autotests, tú escribes mínimo 5 tests unitarios para tu DecisionService, con Mockito, que corran sin PostgreSQL ni red:

# Qué verificar
1 "Stefan destruye la camara" clasifica como RUPTURA_CUARTA_PARED (precedencia de reglas).
2 Un texto sin ninguna letra clasifica como ENTRADA_CORRUPTA y la partida no se modifica.
3 Con impacto CRITICO, lucidity baja 40 y controlLevel sube 45, sin pasarse de los límites.
4 controlLevel = 100 termina la partida con ENDING_PAC_SYMBOL aunque la lucidez también sea 0.
5 publishEvent() se llama una vez en una decisión normal y cero veces en una ENTRADA_CORRUPTA.

Se ejecutan desde la raíz con ./mvnw test. El TA los revisa al cierre.


Entregables

  1. Este repositorio, con tu proyecto en la raíz y todo commiteado.
  2. equipo.json relleno con los 3 integrantes y sus códigos UTEC.
  3. ENTREGA.md en la raíz con: el resumen de estrellas que imprimieron los autotests, una explicación breve del flujo asíncrono que implementaste, y lo que no llegaste a terminar.
  4. La carpeta autotests/ sin modificar.

El .env no se commitea. Si hay credenciales o el JWT_SECRET en el repositorio, el checkpoint no se valida.


Criterios de Evaluación

Resultado Puntos
⭐⭐⭐⭐⭐ Las 5 estrellas en verde + revisión de código conforme 20 puntos
⭐⭐⭐⭐ o menos 0 puntos

Es binario: el juego se publica o no se publica. Las estrellas intermedias sirven para que sepas dónde estás, no para puntaje parcial.

⚠️ Reglas de Oro

  1. No hay frontend. Todo se prueba con los autotests.
  2. PostgreSQL obligatorio. H2 no cuenta.
  3. Nunca toques autotests/. Es la carpeta del TA.
  4. Las contraseñas van con BCrypt y nunca salen en un response.
  5. El dueño sale del token, nunca del request body.
  6. @TransactionalEventListener, no @EventListener.
  7. El listener necesita @Transactional(propagation = REQUIRES_NEW). Con un @Transactional normal la aplicación no arranca.
  8. Nunca serialices entidades JPA. Usa DTOs.
  9. El orden de las reglas importa, tanto en la clasificación como en los finales.
  10. Secretos fuera del repo.

"El árbol se ramifica. Tú crees que eliges la rama. La rama ya te eligió a ti."

— Jerome F. Davies, Bandersnatch, 1968

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages