Elaborado por: TA José Huamani
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.
- Tiempo: 120 minutos exactos
- Equipos: Grupos de 3 estudiantes
- Recursos: Uso de IA permitido, documentación e Internet
- Evaluación: la batería
autotestsque 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.
Sigue estos pasos en orden. No te saltes ninguno.
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" }
]
}docker run --name bandersnatch-db \
-e POSTGRES_DB=bandersnatch \
-e POSTGRES_USER=tuckersoft \
-e POSTGRES_PASSWORD=colin1984 \
-p 5432:5432 \
-d postgres:16Comprueba 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.
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/.
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.iogenera Spring Boot 4, que no es compatible con este enunciado: por eso elpom.xmlviene dado. spring-dotenvno es opcional: es la que lee tu.env. Sin ella la aplicación no arranca.
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_SECRETde al menos 32 caracteres, o la aplicación no arranca (WeakKeyException).
ADMIN_EMAILyADMIN_PASSWORDno 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 conJavaMailSender: si solo guardas la fila enRealityLog, el correo no llega y la ★5 falla.
El
.envya está en el.gitignore. Nunca lo subas.
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.authnimail.smtp.starttls.enable.
Las variables de correo tienen valor por defecto: la app arranca aunque tu
.envesté a medias. El correo solo hace falta para la ★5.
./mvnw spring-boot:runCon eso ya puedes correr los autotests por primera vez. Van a fallar todos, y está bien: te dicen exactamente qué falta.
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 testEn 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:
- Córrelo las veces que quieras: cada corrida usa sus propios datos y no choca con los tuyos.
- Las estrellas van en orden: si la ★2 falla, las siguientes salen como no evaluado.
- Nunca toques la carpeta
autotests/: modificarla anula la hackathon. - Cada corrida publica tus estrellas en el tablero del auditorio si
equipo.jsonestá completo. Si el tablero falla, tus estrellas no cambian. - Si el comando falla, lee
autotests/README.md.
- Java 21
- Spring Boot 3.3.4 (la fija el
pom.xmlde arriba) - Spring Web · Spring Data JPA · Validation
- Spring Security + JWT (
jjwt) - Spring Boot Mail (JavaMail)
- PostgreSQL
@Async+@TransactionalEventListener- JUnit 5 + Mockito
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.
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.
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. |
primaryBranchCodeyglitchBranchCodeson 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.
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:
- Tomar el usuario autenticado del contexto de seguridad.
- Buscar
StoryNodeporstartNodeCode. Si no existe → 404. - Si el
playerTagya existe → 409. - Si
node.currentBranches >= node.branchCapacity→ 400. - Crear la partida con
lucidity=100,controlLevel=0,status=ACTIVA,endingCode=nullystartNodeCode = currentNode.nodeCode. - Incrementar
node.currentBranchesen 1 y guardar el nodo. - Guardar la partida.
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. |
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. |
La API está cerrada. Sin token no se entra a ningún endpoint de negocio.
| 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 |
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 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.
SecurityFilterChainconcsrfdeshabilitado ySessionCreationPolicy.STATELESS.PasswordEncodercomo beanBCryptPasswordEncoder. La contraseña nunca se guarda ni se devuelve en texto plano.- Un
OncePerRequestFilterque leaAuthorization: Bearer <token>, valide la firma y coloque la autenticación en elSecurityContextHolder. - Un
UserDetailsServiceque busque al usuario por email. - Un
AuthenticationEntryPointy unAccessDeniedHandlerque 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.
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,
displayNamede menos de 3 caracteres o contraseña de menos de 6 → 400 - El
rolesiempre esROLE_USER. Si el JSON trae un camporole, se ignora.
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.
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.
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
- Sin cabecera, token inválido o vencido → 401
- Token válido pero sin permiso → 403
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.
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.
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"esRUPTURA_CUARTA_PARED, noREBELDIA, porque la regla 2 se evalúa antes que la 4.
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 Guionva sin tilde, tal cual. Los autotests comparan el String exacto.
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,statusycurrentNodequedan exactamente como estaban. - No se publica el evento, así que no hay correo ni
RealityLog. - El Controller igualmente devuelve 201.
Solo para decisiones que no son ENTRADA_CORRUPTA. Todo ocurre en la misma transacción que guarda la decisión.
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.
Del nodo de origen se elige uno de sus dos códigos de rama:
- Si
branchTypeesRUPTURA_CUARTA_PARED, oimpactLevelesCRITICO→glitchBranchCode - En cualquier otro caso →
primaryBranchCode
Ese valor va a decision.resolvedNodeCode, aunque el nodo no exista.
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
CRITICOseguidas dejancontrolLevel = 100ylucidity = 0en 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.
- Tomar el usuario autenticado y validar que la partida le pertenece
- Validar el request y que la partida esté
ACTIVA - Clasificar el
rawInputy derivarhandlerUnityoutcomeCode - Si es
ENTRADA_CORRUPTA: guardar constatus = ERROR, sin tocar la partida ni publicar evento, y retornar 201 - Aplicar stats, resolver el nodo destino y definir el estado de la partida
- Guardar el Playthrough
- Guardar la
Decisionconstatus = REGISTRADA - Publicar
DecisionCommittedEvent - Retornar 201
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
catchque un fallo de verdad. Nada de escribir el logFAILEDa mano.
Todo lo que no sea /auth/** exige Authorization: Bearer <token>.
| 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"
}| 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"
}
]
}| 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"
}playthroughIdinexistente → 404- partida ajena → 403
- partida
FINALIZADA→ 409 rawInputde menos de 10 caracteres, oimpactLevelfuera 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,controlLevelyendingCodeson 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"
}
]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
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.
Una clase @Configuration con @EnableAsync que registre un ThreadPoolTaskExecutor con:
corePoolSize: 2 ·maxPoolSize: 4 ·queueCapacity: 50threadNamePrefix:"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_NEWla aplicación no arranca: Spring rechaza un@Transactionalnormal 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…).
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
emaildel 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,LucidezyNivel de control, además del id de la decisión, la etiqueta del jugador y el texto original del jugador.
[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.
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 |
Los autotests evalúan en orden y no avanzan si la estrella anterior falló.
- El
DataInitializercrea al admin con la contraseña codificada con BCrypt. registercrea la cuenta y devuelve token; email repetido → 409; datos inválidos → 400.logindevuelve token; contraseña incorrecta → 401.- El
roledel registro siempre esROLE_USER, aunque el request pidaROLE_ADMIN. GET /api/v1/users/mesin token → 401 con el formato de error; con token → 200 sin campopassword.GET /api/v1/userscon usuario normal → 403; con admin → 200.PATCH /api/v1/users/{id}/rolecon usuario normal → 403; con un rol inválido → 400; sobre uno mismo → 400; con un id inexistente → 404.- 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.
POST /api/v1/nodescon token de usuario normal → 403; con admin → 201,currentBranches = 0.nodeCoderepetido → 409;branchCapacity = 0o campos faltantes → 400.GET /api/v1/nodes/{id}devuelve el nodo; id inexistente → 404.
POST /api/v1/playthroughscrea conlucidity=100,controlLevel=0,status=ACTIVA, dueño del token.currentBranchesdel nodo sube a 1.- Nodo lleno → 400;
playerTagrepetido → 409;startNodeCodeinexistente → 404. GET /api/v1/playthroughsdevuelve solo las del usuario; el administrador las ve todas.- Una partida de otro usuario → 403 en detalle y en
/pathpara unROLE_USER, y 200 para el administrador. - El administrador no puede decidir sobre una partida ajena: 403.
/pathde una partida sin decisiones devuelvestepsvacío.
- Las cinco reglas de clasificación dan el
branchTypecorrecto, incluida la de precedencia y la del texto con tildes. handlerUnityoutcomeCodesalen de la tabla.- Los stats cambian exactamente según el impacto, con los límites respetados.
- El nodo destino sale de
primaryBranchCodeoglitchBranchCodesegún la regla, ycurrentNodese mueve. ENTRADA_CORRUPTAdejastatus=ERROR,resolvedNodeCode=nully la partida intacta.- Los tres finales salen en el orden correcto.
- Partida
FINALIZADA→ 409; partida ajena → 403;impactLevelinválido → 400. - Filtros y paginación de
GET /api/v1/decisionsfuncionan.
POST /api/v1/decisionsresponde en menos de 1.5 s constatus = REGISTRADA.- En menos de 25 s la decisión pasa a
ESTABILIZADA. /reality-logstrae un logSENTconsentAtno nulo y el subject exacto.- 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.
- Con
X-Bandersnatch-Simulate: MAIL_FAILURE: 201, logFAILEDconerrorMessage, decisión enERROR, y el sistema sigue aceptando decisiones. /pathrefleja el recorrido completo, sin las decisiones corruptas.
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.
- Este repositorio, con tu proyecto en la raíz y todo commiteado.
equipo.jsonrelleno con los 3 integrantes y sus códigos UTEC.ENTREGA.mden 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.- La carpeta
autotests/sin modificar.
El
.envno se commitea. Si hay credenciales o elJWT_SECRETen el repositorio, el checkpoint no se valida.
| 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.
- No hay frontend. Todo se prueba con los autotests.
- PostgreSQL obligatorio. H2 no cuenta.
- Nunca toques
autotests/. Es la carpeta del TA. - Las contraseñas van con BCrypt y nunca salen en un response.
- El dueño sale del token, nunca del request body.
@TransactionalEventListener, no@EventListener.- El listener necesita
@Transactional(propagation = REQUIRES_NEW). Con un@Transactionalnormal la aplicación no arranca. - Nunca serialices entidades JPA. Usa DTOs.
- El orden de las reglas importa, tanto en la clasificación como en los finales.
- 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
