Backend MVP — Sprint 3
Briefing de implementación del backend de Versus para el Sprint 3. Cubre: multijugador en tiempo real con WebSockets — sala privada, matchmaking, y los tres modos PvP (Duelo Binario, Duelo de Precisión, Sabotaje).
Tarea
Eres el implementador del backend de Versus. Los Sprints 1 y 2 ya están entregados: Auth, Users, Questions, Game singleplayer (Survival, Precision) y Stats básicos funcionan. Ahora toca el multijugador en tiempo real: WebSockets sobre STOMP, salas privadas con código, matchmaking automático, y la lógica de los tres modos PvP.
Contexto que DEBES leer antes de empezar
Lee estos archivos del repo en este orden:
CLAUDE.md(raíz) — visión generaldocs/guia-de-coordinación-técnica.md— contrato de API canónico, sección "Módulo 5 — MATCH"docs/backend-mvp-sprint-1-2.md— briefing previo, te da el modelo de paquetes y patrones que ya están en usodocs/bd-scheme.md— schema final tras los Sprints 1-2- Código existente:
backend/src/main/java/com/versus/api/match/(entidades ya creadas en Sprint 1) ybackend/src/main/java/com/versus/api/game/(lógica singleplayer como referencia)
No leas el frontend salvo si necesitas verificar un evento WebSocket o un payload.
Stack y restricciones
- Java 25, Spring Boot 4.0.5, PostgreSQL 18 (
spring-boot-starter-websocketya añadido en Sprint 1) - STOMP sobre SockJS (estándar Spring)
- Errores REST siguen el formato
{ "error": "CODE", "message": "...", "status": N } - Errores WebSocket: enviar evento
ERRORpor el canal del usuario con el mismo shape - DTOs separados de entidades (regla del proyecto)
- Validación con
jakarta.validationen request DTOs y en payloads STOMP (@Validen handlers)
Cambios al esquema DB
Pequeños:
matches: añadir UNIQUE parcial enroom_code WHERE room_code IS NOT NULL. Las salas privadas usan códigos de 6 caracteres alfanuméricos en mayúsculas.match_players: añadiris_ready BOOLEAN DEFAULT FALSE(para fase de "pulsa Listo" antes de empezar).matchmaking_queue: añadirlast_seen TIMESTAMPpara limpiar entradas zombie tras desconexión.- Cuando termines, actualiza
docs/bd-scheme.md.
Estructura de paquetes (extender la existente)
com.versus.api/
├── config/
│ ├── WebSocketConfig.java (NUEVO — registra /ws, configura STOMP, broker, prefijos)
│ └── WebSocketSecurityConfig.java (NUEVO — autenticación STOMP por JWT en CONNECT)
├── match/
│ ├── controller/
│ │ ├── MatchRestController.java (REST: queue, room, state)
│ │ └── MatchWebSocketController.java (STOMP @MessageMapping handlers)
│ ├── service/
│ │ ├── MatchmakingService.java (cola FIFO por modo)
│ │ ├── RoomService.java (sala privada, código, join)
│ │ ├── MatchOrchestrator.java (estado de partida, rondas, timers)
│ │ ├── modes/
│ │ │ ├── BinaryDuelHandler.java
│ │ │ ├── PrecisionDuelHandler.java
│ │ │ └── SabotageHandler.java
│ │ └── DamageCalculator.java (centraliza fórmulas de los 3 modos)
│ ├── dto/
│ │ ├── ws/ (eventos WS: MatchFoundEvent, MatchStartEvent, QuestionEvent, RoundResultEvent, MatchEndEvent, ErrorEvent)
│ │ └── rest/ (CreateRoomResponse, JoinRoomRequest, MatchStateResponse, QueueRequest)
│ └── domain/ (entidades ya creadas en Sprint 1)
└── common/
└── ws/
├── StompPrincipal.java (Principal con userId)
└── StompAuthChannelInterceptor.java (extrae JWT, valida, establece Principal)
Configuración WebSocket
WebSocketConfig:
- Endpoint:
/wscon SockJS habilitado - Broker simple (en memoria, suficiente para MVP)
- Prefijo aplicación:
/app - Prefijo broker:
/topic,/queue - Prefijo usuario:
/user
WebSocketSecurityConfig (vía ChannelInterceptor):
- En
CONNECT: leer headerAuthorization: Bearer <jwt>, validar conJwtService(ya existe en Sprint 1), establecerStompPrincipalcon eluserId. Si falla → cerrar conexión. - En el resto de comandos (
SUBSCRIBE,SEND): verificar que hay Principal.
Lógica común de partida multijugador
Modelo de Match: ya está en BD. Una partida PvP = Match con status evolucionando WAITING → IN_PROGRESS → FINISHED, dos MatchPlayer (no más en MVP), N MatchRound con FK a Question, y MatchAnswer por jugador y ronda.
Orquestación de ronda (servidor-autoritativo):
MatchOrchestratormantiene en memoria el estado activo de la partida (Map<UUID matchId, MatchState>). Persiste cambios relevantes en BD (round creado, answer guardada, match finalizado).- Al empezar partida: emite
MATCH_STARTcon la 1ª pregunta a/topic/match/{id}. - Lanza un timer de 15 segundos por ronda. Si un jugador no responde, se asume respuesta vacía (peor desviación / fallo).
- Cuando ambos jugadores han respondido (o expira el timer), llama al
*ModeHandlercorrespondiente para calcularlifeDeltapor jugador. - Emite
ROUND_RESULTa/topic/match/{id}. - Si alguien llega a
livesRemaining ≤ 0o se han jugado N rondas (límite por modo), finaliza: - Persiste
Match.status=FINISHED,finished_at,MatchPlayer.result - Emite
MATCH_ENDcon ganador y stats - Actualiza
player_statsyrankingsde ambos jugadores - Si no, emite siguiente
QUESTION.
Vidas iniciales por modo:
| Modo | Vidas | Tipo de pregunta | Rondas máx |
|---|---|---|---|
BINARY_DUEL |
3 | BINARY | 15 |
PRECISION_DUEL |
100 | NUMERIC | 10 |
SABOTAGE |
100 | NUMERIC | 10 |
Selección de pregunta: al iniciar cada ronda, pedir una pregunta aleatoria del tipo correspondiente que no haya aparecido aún en la partida. Si se acaban, terminar partida.
Lógica de daño por modo
Centralizar en DamageCalculator. Marcar cada fórmula con // TODO(#XX): confirmar con el equipo.
Duelo Binario (issue #68)
- Ambos responden, el que acierta no pierde vida.
- El que falla:
lifeDelta = -1. - Si ambos fallan: ambos
-1. - Si ambos aciertan: nadie pierde, sigue.
Duelo de Precisión (issue #71)
- Ambos responden NUMERIC, se calcula
dev_i = |answer_i - correct| / correct * 100. - El de menor
dev:lifeDelta = 0. - El de mayor
dev:lifeDelta = -min(50, round(|dev_loser - dev_winner|)). - Empate técnico (
|dev_a - dev_b| < 0.01): ambos0.
Sabotaje (issue #74)
- Ambos responden NUMERIC. El que acierta mejor daña al rival en proporción a su acierto.
accuracy_winner = max(0, 100 - dev_winner)(cuanto más cerca, más daño hace).lifeDelta_loser = -min(50, round(accuracy_winner / 2)).lifeDelta_winner = 0.- Empate técnico: ambos
0.
Endpoints REST
Todos bajo /api/match/*, autenticados (PLAYER+).
| Método | Ruta | Descripción |
|---|---|---|
POST |
/api/match/queue |
Body: { "mode": "BINARY_DUEL" }. Inserta en matchmaking_queue (UNIQUE user+mode). Si ya hay otro esperando con el mismo modo, los empareja inmediatamente y emite MATCH_FOUND a ambos. Devuelve { "status": "WAITING" \| "MATCHED", "matchId": "..." }. |
DELETE |
/api/match/queue |
Quita al usuario de la cola. 204. |
POST |
/api/match/room |
Body: { "mode": "..." }. Crea Match con room_code único de 6 chars, status=WAITING, añade al creador como MatchPlayer. Devuelve { "matchId", "roomCode" }. |
POST |
/api/match/room/join |
Body: { "roomCode": "ABC123" }. Busca match, valida status=WAITING y solo 1 player, añade al usuario, emite MATCH_FOUND a ambos. 409 si llena, 404 si no existe. |
GET |
/api/match/{matchId} |
Estado actual de la sala. Solo si el usuario es uno de los MatchPlayer. |
Endpoints WebSocket
Cliente → Servidor
| Destino | Payload | Acción |
|---|---|---|
/app/match/ready |
{ "matchId": "..." } |
Marca MatchPlayer.is_ready=true. Cuando ambos están ready, dispara MATCH_START. |
/app/match/answer |
{ "matchId", "questionId", "optionId" \| "value" } |
Registra respuesta del jugador. Si ambos han respondido o expiró timer, dispara ROUND_RESULT. Validar tipo según modo. |
/app/match/leave |
{ "matchId": "..." } |
Abandono voluntario. El otro gana automáticamente. |
Servidor → Cliente
Eventos publicados por el MatchOrchestrator:
| Evento | Canal | Payload |
|---|---|---|
MATCH_FOUND |
/user/queue/match |
{ "type": "MATCH_FOUND", "matchId", "mode", "opponent": { "id", "username", "avatarUrl" } } |
MATCH_START |
/topic/match/{id} |
{ "type": "MATCH_START", "mode", "question", "timeLimit": 15, "initialLives": N } |
QUESTION |
/topic/match/{id} |
{ "type": "QUESTION", "roundNumber", "question", "timeLimit": 15 } |
ROUND_RESULT |
/topic/match/{id} |
{ "type": "ROUND_RESULT", "roundNumber", "answers": [{ "userId", "answerGiven", "isCorrect", "deviation" }], "lifeDeltas": { "<userId>": N }, "livesRemaining": { "<userId>": N }, "correctAnswer": "..." } |
MATCH_END |
/topic/match/{id} |
{ "type": "MATCH_END", "winnerId" \| null, "result": "WIN" \| "DRAW" \| "ABANDONED", "stats": { "<userId>": { "correctAnswers": N, "avgDeviation": N \| null } } } |
OPPONENT_LEFT |
/topic/match/{id} |
{ "type": "OPPONENT_LEFT", "userId" } |
ERROR |
/user/queue/match |
{ "type": "ERROR", "error": "CODE", "message": "..." } |
Todos los eventos deben llevar el campo
type(discriminador para el frontend).
Lo que tienes que implementar (orden)
WebSocketConfig+StompAuthChannelInterceptor+StompPrincipal. Test: conectar con JWT válido funciona, sin token rechaza el CONNECT.- REST de salas:
POST /match/room,POST /match/room/join,GET /match/{id}. Test integración con dos usuarios mock. - REST de matchmaking:
POST /match/queue,DELETE /match/queue, con FIFO simple por modo. MatchOrchestratoresqueleto: estado en memoria, persistencia de rondas, timer de ronda. Sin lógica de modos aún.- STOMP handlers:
/app/match/ready,/app/match/answer,/app/match/leave. BinaryDuelHandler+ integración end-to-end (dos clientes conectan, juegan, alguien gana,MATCH_ENDse emite).PrecisionDuelHandler.SabotageHandler.- Actualización de
player_statsyrankingsal finalizar partida (usa servicios de Stats del Sprint 1-2; añade métodorecordPvpMatch). - Limpieza: scheduler
@Scheduledcada 30s que purgamatchmaking_queueconlast_seenantiguo (>60s). - Tests:
- Unit: cada
*Handlercon casos de daño borde. - Integración: flujo completo BINARY_DUEL con dos clientes STOMP (
@SpringBootTest+WebSocketStompClient).
- Unit: cada
Manejo de desconexiones (mínimo viable)
SessionDisconnectEvent→ si el usuario estaba en una partidaIN_PROGRESS, marcar comoABANDONED, dar la victoria al otro, emitirMATCH_END.- Si estaba en cola de matchmaking, eliminar de la cola.
Lo que NO debes hacer
- No implementar reconexión / "rejoin in progress match" → futuro.
- No implementar más de 2 jugadores por partida.
- No implementar chat / emotes.
- No tocar moderación, admin, scraping, ranking endpoints (aunque sí debes actualizar la tabla
rankingsal finalizar partida) → Sprint 4. - No tocar el frontend.
- No usar Redis / RabbitMQ → broker simple en memoria es suficiente para MVP.
Entrega
- Resumen de lo implementado y de los tests añadidos (especial atención a los tests STOMP end-to-end).
- Lista de TODOs marcados (especialmente fórmulas de daño).
- Instrucciones para probar manualmente con dos clientes (curl + un cliente STOMP, p.ej.
wscato un script Java). docs/bd-scheme.mdactualizado.
Cuando dudes
Pregunta. Especialmente sobre:
- Fórmulas de daño (los TODOs)
- Estrategia de timeout y desconexión
- Si los tests STOMP requieren librerías nuevas