Initialize games2 foundation

main
dev 2 days ago
commit 0982ba0e52

@ -0,0 +1,2 @@
# Backend development address. The frontend development proxy targets this port.
GAMES2_API_ADDR=127.0.0.1:8082

18
.gitignore vendored

@ -0,0 +1,18 @@
# Frontend and Go build outputs
frontend/node_modules/
frontend/dist/
frontend/.svelte-kit/
node_modules/
bin/
*.exe
.cache/
# Local configuration and temporary files
.env
.env.*
!.env.example
.data/
coverage/
*.log
.npm-cache/
frontend/.npm-cache/

@ -0,0 +1,72 @@
# Juegoland — games2
Proyecto independiente para la nueva plataforma de juegos. El frontend está en Svelte 5 y TypeScript; la API y el servidor de dominio están en Go. La carpeta no importa archivos de `games`, usa puertos propios y no modifica su despliegue.
**Estado actual:** base ejecutable. El catálogo está conectado desde el navegador a la API Go y empieza vacío porque ningún juego está instalado/publicado en este proyecto. Salas, cuentas, chat, motor, PostgreSQL y tiempo real aún no están habilitados. `/ready` describe expresamente esta fase como `foundation`; no acredita preparación para producción.
## Requisitos
- Go 1.26 o superior.
- Node.js 24 o superior y npm.
## Desarrollo
En una terminal, desde la raíz de `games2`:
```powershell
go run ./cmd/games2
```
La API escucha por defecto en `127.0.0.1:8082`. Se puede elegir otro puerto con `GAMES2_API_ADDR`. Para usar otro puerto hay que actualizar también el destino del proxy de desarrollo en `frontend/vite.config.ts`.
En otra terminal:
```powershell
cd frontend
npm ci
npm run dev
```
Abre `http://127.0.0.1:4174`. El frontend consulta `/api/v2/catalog` a través del proxy local hacia Go. En producción, un proxy HTTPS servirá el frontend estático y enviará `/api` al backend; esta configuración aún no está desplegada.
Las herramientas para validar los contratos del motor tienen dependencias propias en la raíz del proyecto:
```powershell
npm ci
npm run contracts:check
```
Comprobar:
```powershell
go test ./...
npm run contracts:check
cd frontend
npm run check
npm run build
```
Rutas de la primera fase: `GET /live`, `GET /ready`, `GET /api/v2/bootstrap` y `GET /api/v2/catalog`. Las dos últimas devuelven `requestId` y `data`; el catálogo devuelve `items: []` y `nextCursor: null` hasta la primera publicación. No hay rutas de creación o publicación de juegos para usuarios.
## Estructura
```text
cmd/games2/ arranque y apagado del proceso Go
internal/api/httpapi/ entrega HTTP y contratos de respuesta
internal/server/ casos de uso y capacidades habilitadas
internal/domain/catalog/ lectura de juegos publicados
internal/domain/ identidad, seguridad, salas, chat y moderación
internal/engine/ núcleo del motor propio
internal/games/ reglas Go propias publicadas internamente
internal/infra/ PostgreSQL y proveedores externos
internal/jobs/ plazos y outbox
frontend/ aplicación Svelte 5 y Vite
contracts/ índice de contratos ejecutables
db/migrations/ futuras migraciones PostgreSQL
deploy/ futuro despliegue independiente
docs/platform/ políticas y arquitectura de producto
docs/game-engine/ formato, esquemas y ejemplos del motor propio
docs/ROADMAP.md próximos hitos y criterios de aceptación
```
El directorio `docs/` contiene una copia de los contratos y propuestas de v2 para trabajar sin depender de archivos de la aplicación anterior. Documentan el destino; las funciones no disponibles siguen marcadas como pendientes. La [arquitectura](docs/platform/architecture.md), las [políticas](docs/platform/product-policies.md) y el [plan de implementación](docs/ROADMAP.md) indican los límites de esta primera fase.

@ -0,0 +1,54 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"juegoland/games2/internal/api/httpapi"
"juegoland/games2/internal/domain/catalog"
"juegoland/games2/internal/server"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
address := os.Getenv("GAMES2_API_ADDR")
if address == "" {
address = "127.0.0.1:8082"
}
// The first release includes no games. A game appears only when its rules,
// assets and publication record are installed and validated together.
registry := catalog.NewRegistry(nil)
service := server.NewService(registry, time.Now)
httpServer := &http.Server{
Addr: address,
Handler: httpapi.NewHandler(service),
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
logger.Error("graceful shutdown failed", "error", err)
}
}()
logger.Info("games2 API listening", "address", address)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("API stopped", "error", err)
os.Exit(1)
}
}

@ -0,0 +1,5 @@
# Contratos ejecutables
El formato y los esquemas de juegos copiados están en [`../docs/game-engine/`](../docs/game-engine/). Su fuente Zod/TypeScript puede validarse con `npm run contracts:check` desde la raíz de `games2`.
El contrato OpenAPI de plataforma, los esquemas de eventos y las interfaces Go equivalentes al SDK de reglas son el siguiente entregable. Los tipos del frontend y las estructuras Go de esta base se limitan al catálogo y al bootstrap ya implementados. No se presentarán como contrato completo de cuentas, salas o chat.

@ -0,0 +1,3 @@
# Migraciones de games2
La base inicial no abre PostgreSQL y no declara un esquema vacío como migración de producción. La primera migración llegará junto con una unidad de trabajo y pruebas de integración para cuentas, catálogo y recibos. Se versionará aquí de forma independiente de las migraciones de `games`.

@ -0,0 +1,3 @@
# Despliegue de games2
La base solo arranca en `127.0.0.1:8082` para desarrollo. No hay unidad systemd, proxy público ni configuración de producción para games2. Cuando se habiliten PostgreSQL, migraciones, disponibilidad de reglas y observabilidad, esta carpeta contendrá artefactos propios y un despliegue reversible separado de la aplicación anterior.

@ -0,0 +1,23 @@
# Plan de implementación de games2
## Base ejecutable actual
- Proyecto Go con API, capa de servidor y catálogo de publicación interna; sin juegos fingidos.
- Frontend Svelte independiente que consulta el catálogo real y representa carga, fallo y lista vacía.
- Bootstrap de capacidades: únicamente catálogo. Salud de proceso y readiness acotado a esta fase.
- Contratos y políticas de v2 copiados bajo `docs/`; su implementación permanece pendiente.
## Próximos cortes verticales
1. **Persistencia y contratos:** PostgreSQL, migraciones nuevas, repositorios dentro de una unidad de trabajo, API/esquemas verificables y pruebas de integración. `/ready` pasará a exigir base y esquema compatibles cuando se active esa dependencia.
2. **Motor propio en Go:** interfaz de reglas equivalente al SDK documentado, perfil de paquete Go versionado, resultados, azar determinista, proyecciones por jugador, recibos y outbox. Conecta 4 como primer juego propio real. No publicarlo solo por existir una ficha.
3. **Información privada y azar:** Hundido y Brisca como pruebas de vistas ocultas, acciones independientes, barajas y reproducibilidad. Conservar fixtures de resultados y privacidad.
4. **Identidad y protección:** credenciales recuperables, sesiones, edad, tutela, permisos de sala, invitaciones, bloqueo/reporte y administración. Abrir cada capacidad solo con sus proveedores, procedimientos y pruebas.
5. **Tiempo real y clientes:** binding WebSocket versionado, negociación, auth, reconexión, cursores y recibos; frontend de sala/partida con móvil y accesibilidad probados.
6. **Operación:** backups/restauración con supresiones, observabilidad externa del proxy y del backend, pruebas de carga, despliegue reversible y migración desde v1 con una sola autoridad por sala.
## Criterio para cada hito
Cada entrega incluye una ruta visible, su comportamiento de error y permisos, pruebas que cubran el riesgo propio, instrucciones de operación y una comprobación contra los documentos de contrato. Las capacidades no implementadas permanecen desactivadas en `bootstrap` y sin controles de interfaz que sugieran que funcionan.
La primera fase no requiere conexión a la base de datos ni proveedor externo. El trabajo posterior debe mantener los valores de producto de [políticas](platform/product-policies.md), la [API/eventos](platform/api-and-events.md) y el [formato de juegos](game-engine/format-and-protocol.md), actualizando esos documentos cuando una decisión de implementación cambie un contrato.

@ -0,0 +1,451 @@
# Motor propio de Juegoland: especificación v1
Estado: diseño consolidado el 5 de octubre de 2026, con contratos y validadores ejecutables. El motor de producción conserva todavía su implementación actual. Esta especificación define el objetivo del refactor; no certifica que el servidor ya lo implemente.
La decisión es construir un motor propio con paquetes de juego, reglas autoritativas en el servidor y presentación independiente. El primer alcance son los juegos actuales por turnos y las acciones simultáneas independientes de Hundido. El modelo también representa solitarios, equipos y cooperación, sin obligar a implementar ahora nuevos juegos, un editor visual, partidas masivas o una plataforma de código subido por terceros.
Actualización de alcance: el usuario confirma que todos los juegos serán propios y los publicará exclusivamente el equipo. La [arquitectura de plataforma](platform/architecture.md) fija API Go y recomienda servidor/motor Go; esta especificación v1 conserva por ahora su SDK TypeScript y perfil de módulos. Las funciones TS no se ejecutan nativamente en Go: se requiere SDK equivalente y perfil versionado de paquete antes de portar reglas. El protocolo JSON y los recursos pueden conservar su semántica; cambiar lenguaje no demuestra equivalencia ni cambia automáticamente el transporte Socket.IO.
## 1. Qué constituye la especificación
| Artefacto | Responsabilidad |
| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| [schema.ts](game-engine/schema.ts) | Fuente de los esquemas estructurales y validadores semánticos comunes. |
| [contracts.ts](game-engine/contracts.ts) | SDK de funciones de reglas, componentes, persistencia y presentación. |
| [package.schema.json](game-engine/package.schema.json) | Manifiestos de juegos y bibliotecas. |
| [component.schema.json](game-engine/component.schema.json) | Barajas, dados, ruletas, tableros, piezas y extensiones. |
| [resource-set.schema.json](game-engine/resource-set.schema.json) | Recursos visuales asociados a las definiciones de un componente. |
| [lock.schema.json](game-engine/lock.schema.json) | Versiones resueltas y hashes de archivos del paquete publicado. |
| [protocol.schema.json](game-engine/protocol.schema.json) | Mensajes del protocolo de partidas. |
| [Formato y protocolo](game-engine/format-and-protocol.md) | Documento independiente del paquete y de los mensajes de red. |
| [presentation-plan.schema.json](game-engine/presentation-plan.schema.json) | Planes de animación y sonido. |
| [Ejemplos y perfiles](game-engine/examples/README.md) | Manifiestos completos como registros de datos y escenarios de conformidad. |
| [verify.ts](game-engine/verify.ts) y [type-check.ts](game-engine/type-check.ts) | Comprobaciones de estructura, referencias, invariantes y tipos. |
Los seis esquemas JSON se generan desde Zod, con dialecto JSON Schema 2020-12; no se mantienen a mano. Sus identificadores `urn:juegoland:…:1` son identificadores del contrato, no servicios web. El SDK infiere de esos esquemas las estructuras compartidas cuando procede. Las firmas de las funciones permanecen en TypeScript: JSON no contiene funciones.
Hay cuatro niveles de validación: estructura del documento, coherencia entre sus campos y dependencias, existencia y contenido de los archivos del paquete, y comportamiento de las reglas y del runtime. Los validadores adjuntos cubren los dos primeros en los casos indicados; no sustituyen el futuro compilador de paquetes ni las pruebas del motor.
## 2. Referencias publicadas y decisiones propias
No hay en las fuentes consultadas un único formato que resuelva conjuntamente los requisitos de esta plataforma. Se toman ideas concretas, conservando un contrato propio y pequeño.
| Fuente primaria | Qué aporta al diseño |
| ------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| [Foundry VTT: Game System Development](https://foundryvtt.com/article/system-development/) | Manifiesto de sistema, compatibilidad, dependencias, módulos, recursos y evolución de modelos. |
| [Tabletop Playground: Packages](https://tabletop-playground.com/knowledge-base/packages/) | Paquetes con manifiesto, miniatura, objetos, texturas y scripts; reutilización de componentes. |
| [boardgame.io: contratos de tipos](https://github.com/boardgameio/boardgame.io/blob/main/src/types.ts) | Separación entre configuración inicial, movimientos, fases, etapas y vistas por jugador. |
| [Board Game Arena: estados](https://en.doc.boardgamearena.com/State_classes:_State_directory) | Participación de un jugador, varios jugadores y estados automáticos como conceptos diferentes. |
| [Board Game Arena: interfaz y notificaciones](https://en.doc.boardgamearena.com/Game_interface_logic:_yourgamename.js#Notifications) | Secuenciar efectos mediante promesas y resolverlos también cuando se desactivan las animaciones. |
| [Tabletop Simulator: Save File Format](https://kb.tabletopsimulator.com/custom-content/save-file-format/) | Serialización de objetos, recursos y scripts. Útil para componentes; no equivale al contrato de reglas autoritativas de Juegoland. |
| [Ludii: Language Reference](https://ludii.games/downloads/LudiiLanguageReference.pdf) | Separación declarativa de jugadores, equipamiento y reglas. No se adopta su lenguaje ni se crea otro DSL universal en v1. |
| [Socket.IO: Delivery guarantees](https://socket.io/docs/v4/delivery-guarantees/) | El orden de los mensajes entregados no garantiza su entrega. La fiabilidad de comandos y sincronización corresponde al protocolo de aplicación. |
| [JSON Schema 2020-12](https://json-schema.org/draft/2020-12) y [Zod: JSON Schema](https://zod.dev/json-schema) | Formato verificable y generación de esquemas a partir de la definición canónica. |
Las políticas de concurrencia, proyección, recursos, idempotencia y presentación descritas a continuación son decisiones de Juegoland. No se atribuyen a esos proyectos ni requieren importar sus motores.
## 3. Responsabilidades y límites
| Capa | Es responsable de |
| --------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Plataforma | Identidad, salas, permisos, pertenencia, transporte y almacenamiento. |
| Núcleo del motor | Carga de versiones, validación, serialización de comandos, revisiones, azar, transacciones, recibos y plazos. |
| Módulo de reglas | Legalidad, datos del juego, fases, actores, resultados, retiradas y proyección de información. |
| Biblioteca de componentes | Definiciones reutilizables y operaciones genéricas sin reglas particulares de un juego. |
| Paquete de presentación | Renderizado, recursos, accesibilidad y planes de efectos. |
| Coordinador de presentación | Vista confirmada y mostrada, ejecución de planes, cancelación, audio y recuperación. |
La [especificación de plataforma v2](platform-spec-v2.md) y su [API/eventos](platform/api-and-events.md) concretan identidad, salas, chat, invitaciones y operación. La plataforma proporciona almacenamiento y transporte; aplicación y núcleo comparten una única unidad de trabajo para cambios coordinados de sala/partida. El núcleo controla la transición y proyección de partida, sin confirmar una transacción independiente de la sala. La adaptación del SDK debe permitir participar en esa unidad de trabajo.
```mermaid
flowchart LR
U[Acción del usuario] --> C[Comando validado]
C --> N[Núcleo y reglas]
N --> T[Transacción: estado, recibo y publicación]
T --> P[Proyección por destinatario]
P --> V[Vista confirmada]
V --> Q[Coordinador de presentación]
Q --> R[Vista mostrada]
```
El servidor decide el resultado de una tirada o un movimiento antes de cualquier animación. Ningún efecto, sonido ni confirmación visual del cliente determina una regla o impide que el servidor avance.
## 4. Formato de paquete
El manifiesto se llama `game.json` para juegos y `package.json` para bibliotecas dentro de su directorio de distribución. Su campo `format` es siempre `juegoland.package`; el nombre del archivo no lo convierte en un paquete npm.
Un juego contiene obligatoriamente:
- Identidad, versión exacta, autores, licencia, descripción e idiomas.
- Clasificación: categoría, etiquetas y modalidad competitiva, cooperativa, por equipos, individual, solitaria o abierta.
- Intervalo de participantes y capacidades de información oculta, acciones simultáneas y plazos.
- Componentes declarados: tipo, definición local o exportación de una dependencia y número de copias.
- Entrada de reglas, versión de reglas y esquemas de configuración, datos, vista, contexto, eventos internos y visibles.
- Entrada de presentación, versión del bundle, recursos, asociaciones visuales de componentes y portada, miniatura e icono.
Las bibliotecas tienen `kind: components` o `kind: assets` y exportaciones con nombre. Una referencia a dependencia es un objeto inequívoco:
```json
{
"source": "dependency",
"packageId": "playing-cards",
"export": "poker52"
}
```
La versión se resuelve en `dependencies`: por ejemplo, `playing-cards@1.0.0`. No se admiten `latest`, rangos, `@1` ni referencias implícitas. Una referencia local tiene `source: local` y una ruta relativa normalizada.
Las bibliotecas de componentes solo dependen de componentes; las de recursos, de recursos. El juego puede depender de ambas. Esto permite cargar las reglas sin cargar imágenes y compilar el cliente sin incorporar el módulo privado de reglas.
Se rechazan dependencias circulares, autorreferencias, exportaciones inexistentes y dos versiones del mismo identificador dentro del árbol de un paquete. El registro general sí puede alojar diferentes versiones utilizadas por juegos distintos. Los campos desconocidos del contrato se rechazan; las extensiones futuras requerirán un campo o una versión explícitos.
### Publicación y lock
El proceso de publicación debe resolver todas las dependencias y producir un lock inmutable con hashes SHA-256 reales, tamaño, tipo de contenido y URL pública de cada archivo cuando corresponda. Los archivos privados de reglas tienen `url: null` y no se entregan al navegador. La API pública de recursos expone únicamente el subconjunto autorizado del lock.
Para evitar hashes circulares, el digest de un paquete se calcula sobre su manifiesto y el inventario ordenado de sus archivos propios, excluyendo el lock. Cada archivo se identifica por sus bytes exactos. La representación del digest es `{ manifest, files: [{ path, sha256, bytes, mediaType }] }`, con archivos ordenados por ruta, serializada en UTF-8 mediante [JCS, RFC 8785](https://www.rfc-editor.org/rfc/rfc8785). El digest del lock se calcula después de resolver los paquetes con la misma canonicalización; sus dependencias se ordenan por identificador y sus archivos por identificador y ruta. Nunca se usa una URL mutable como identidad de contenido.
El publicador comprobará rutas resueltas dentro del paquete, archivos, exportaciones, tipos de contenido, traducciones, hashes y compatibilidad. Las entradas TypeScript se compilan a artefactos separados de servidor y cliente; el inventario de distribución refleja esas rutas finales. Un ejemplo de manifiesto de fuente no se considera instalable sin ese proceso.
## 5. Versiones y partidas existentes
| Campo | Qué cambia |
| ---------------------------- | -------------------------------------------------------------------------- |
| `formatVersion` | Forma del manifiesto; v1 en esta especificación. |
| `engineApiVersion` | Contrato del SDK de reglas y servicios. |
| `version` del paquete | Cualquier cambio publicado en el contenido del paquete. |
| `rules.version` | Comportamiento de las reglas; incluye correcciones que cambien resultados. |
| `stateSchemaVersion` | Forma persistida de los datos privados del juego. |
| `viewSchemaVersion` | Vista y contexto entregados al cliente. |
| `eventSchemaVersion` | Eventos internos y visibles, con sus respectivos esquemas. |
| `presentation.apiVersion` | Contrato del renderer y los efectos. |
| `presentation.bundleVersion` | Recursos y comportamiento visual. |
| `protocolVersion` | Sobres y semántica de los mensajes de red. |
Una partida fija el paquete, las reglas, las dependencias y sus digests al crearse. Una corrección visual no actualiza silenciosamente ese lock durante la partida. Una publicación nueva puede reutilizar la misma versión de reglas solo si el artefacto de reglas y sus dependencias lógicas son idénticos.
Los módulos antiguos permanecen disponibles mientras existan partidas que los requieran. Una migración de estado es una operación administrativa explícita, con origen, destino, validación y posibilidad de restauración; nunca un alias que interprete datos antiguos con reglas nuevas. La migración concreta de partidas actuales se decidirá después de disponer de fixtures de sus estados reales.
La negociación del cliente declara tuplas completas `{ id, rulesVersion, viewSchemaVersion, eventSchemaVersion }`. No se combinan listas independientes de versiones que puedan producir una compatibilidad inexistente. Un cliente incompatible recibe `INCOMPATIBLE_CLIENT`; no recibe una vista que no sabe interpretar.
## 6. Componentes: definiciones, instancias y zonas
Una baraja compartida define las cartas posibles, no una partida ni su aspecto. Solitario referencia `playing-cards/poker52`; su presentación referencia `cards-poker/standard`. Brisca puede usar otra definición y otro conjunto de ilustraciones.
| Tipo | Definición |
| -------- | ----------------------------------------------------------------------------- |
| `deck` | Cartas con identificador, palo, rango y atributos. |
| `dice` | Dados, caras, valores y pesos enteros positivos. |
| `wheel` | Sectores y pesos; las reglas interpretan el sector seleccionado. |
| `board` | Cuadrícula o grafo de casillas y conexiones dirigidas. |
| `pieces` | Tipos de piezas, cantidades y atributos. |
| `custom` | Extensión con `typeId`, datos y validador registrado por código de confianza. |
No se presupone que el rango de una carta determine su valor en todos los juegos. Brisca y Solitario asignan orden y puntuación en sus propias reglas. Las conexiones de un grafo no se hacen bidireccionales automáticamente.
Las instancias tienen identificador estable dentro de la partida, `definitionId`, `componentId` e índice de copia. Dos barajas generan 104 instancias de 52 definiciones; dos ases iguales no comparten identidad. La creación usa un espacio de nombres determinista por componente y copia.
Cada carta pertenece a exactamente una zona. Una zona tiene propietario opcional, orden, visibilidad declarada e identificadores de sus cartas. Los movimientos entre zonas son atómicos: no pierden, duplican ni crean instancias. La creación inicial las coloca en una zona `supply`; las reglas distribuyen desde ella. Los ayudantes devuelven estructuras nuevas.
La visibilidad de una zona ayuda a validar, pero no sustituye a `project`. Una carta oculta no debe revelar su identidad estable: incluso sin mostrar su cara, permitiría seguirla después de barajar. El rival recibe cantidades o representaciones opacas adecuadas a la regla.
Dados y ruletas usan el azar del servidor. Las caras o sectores visuales representan ese resultado; una simulación física del navegador no lo elige. Un componente `custom` no registrado impide cargar el juego, con un error explícito.
## 7. Recursos, ilustraciones y catálogo
El bundle declara todos los recursos propios y dependencias necesarias: imágenes, atlas, sonidos, fuentes o gráficos generados mediante código de presentación. Portada, miniatura e icono son referencias obligatorias a recursos de catálogo; pueden ser imágenes o gráficos. No se deducen del nombre del juego.
Cada recurso declara ruta, tipo, estrategia de carga, si es necesario y un fallback. Imágenes y atlas declaran dimensiones; las regiones del atlas deben quedar dentro de ellas. Los textos alternativos y fallbacks de texto usan claves de traducción.
Un recurso `graphic` exporta `GraphicDefinition`: genera un SVG nuevo a partir del identificador de recurso, slot, definición e idioma. Su propietario inserta y retira ese SVG. El gráfico no crea temporizadores ni envía comandos; la animación pertenece al coordinador. Así quedan definidos también los recursos procedurales y se puede usar un mismo módulo para todas las caras de una baraja.
Las estrategias son `catalog`, `board`, `phase` y `lazy`. Una carga por fase declara exactamente su fase. Un recurso necesario puede sustituirse por texto o un recurso alternativo, pero su cadena de fallbacks no puede terminar en omisión. Un sonido opcional puede omitirse. Los fallbacks no excusan archivos ausentes en una publicación: permiten recuperarse de fallos de carga en el cliente.
Una asociación visual de componente apunta a un `ResourceSet`: recursos, slots por identificador de definición, reverso opcional y proporción visual. El publicador valida la cobertura de las definiciones que deban poder mostrarse y la existencia de las regiones referenciadas. Una baraja oculta necesita reverso; una baraja completa necesita una representación para cada cara, aunque proceda de un renderer común.
Un juego de rol debe declarar sus mapas, personajes, objetos, iconos y sonidos propios o referenciarlos en dependencias exactas. Sus entidades y reglas pueden usar componentes `custom`. El formato admite el paquete de recursos; un sistema de rol concreto necesitará además su modelo de personajes, acciones y proyección. No se pretende que un campo `category: roleplaying` implemente esas reglas.
Los archivos públicos pueden permitir conocer las ilustraciones disponibles. La posición secreta de un barco o el reparto real de cartas nunca se codifican en nombres de URL, recursos solicitados observables por rivales ni manifiestos por partida compartidos.
## 8. Esquema fijo de funciones de reglas
Las firmas exactas están en [contracts.ts](game-engine/contracts.ts). Los datos cruzan el límite del módulo como JSON finito, sin funciones, fechas implícitas, mapas, referencias cíclicas ni instancias de clases.
| Función | Obligación |
| --------------------------------------------------- | ---------------------------------------------------------------------------- |
| `setup(input, services)` | Crear la transición inicial con datos, flujo, resultado, eventos y plazos. |
| `validatePosition(position)` | Comprobar invariantes globales del juego; lanzar un error si hay corrupción. |
| `actions[type].schema` | Validar el comando específico, incluidos sus parámetros y campos permitidos. |
| `actions[type].apply(position, command, services)` | Rechazar una acción ilegal o devolver la siguiente transición completa. |
| `availableActions(position, recipient)` | Proponer acciones concretas o parametrizadas visibles para ese destinatario. |
| `project(position, recipient)` | Construir vista, contexto y resultado autorizados. |
| `projectEvent({ before, after, event, recipient })` | Emitir cero o más eventos visibles derivados de cada evento interno. |
| `onLifecycle(position, event, services)` | Resolver una retirada o un plazo vencido según las reglas del juego. |
Cada manejador declara `conflictPolicy: match | actor`. El núcleo añade las precondiciones correctas a las acciones ofrecidas; no confía en precondiciones inventadas por la presentación. Las ofertas parametrizadas identifican el esquema registrado de la acción y sus restricciones visibles; no enumeran millones de coordenadas o combinaciones.
Todas las funciones de reglas son síncronas. No acceden a red, base de datos, DOM, archivos, `Math.random()` ni `Date.now()`. `apply` devuelve `{ ok: false, code: ILLEGAL_ACTION }` para una jugada inválida; una excepción significa fallo interno y no confirma cambios parciales.
Los argumentos usan `DeepReadonly`. Esto protege también arrays y objetos anidados en TypeScript, pero no es una barrera en ejecución: el núcleo debe aislar las estructuras y congelarlas en desarrollo, validar las salidas y probar ausencia de mutación. La limitación de `readonly` está documentada en [TypeScript](https://www.typescriptlang.org/docs/handbook/2/objects.html#readonly-properties).
### Servicios deterministas
`RuleServices` expone definiciones inmutables, un instante capturado para la operación, azar etiquetado y ayudantes de cartas. Las etiquetas identifican cada extracción y son únicas en la operación. Las opciones y límites del azar se validan; el resultado real se registra en una traza privada junto con su orden, etiqueta y parámetros.
En ejecución normal, el servidor obtiene entropía segura. En reproducción consume la traza registrada y falla si cambian las peticiones. No se publica una semilla que permita predecir futuras cartas. `shuffle` no modifica su entrada; las selecciones ponderadas respetan los pesos sin sesgo por reducción modular.
`project`, `projectEvent`, `availableActions` y `validatePosition` son puras y no reciben azar. La misma posición y destinatario producen la misma salida.
### Plazos y retiradas
Un plazo es una intención persistida con identificador, vencimiento y payload interno. El núcleo asigna una generación a cada programación; reemplazar o cancelar invalida disparos anteriores. Al vencer, genera una operación interna idempotente para `onLifecycle`, valida la generación bajo el bloqueo de la partida y confirma consumo y transición juntos.
Una retirada es una operación de plataforma autorizada. El módulo recibe la posición anterior y el evento; el núcleo conserva el participante con estado `withdrawn` en la posición siguiente. Las reglas deben retirarlo de los actores habilitados y decidir continuidad o resultado. Una desconexión de transporte no equivale por defecto a una retirada.
Las operaciones automáticas inmediatas se resuelven dentro de la función que las desencadena, con ejecución acotada. Una fase automática persistida debe tener un plazo duradero que la haga avanzar; v1 no tiene callbacks automáticos ocultos ni llamadas recursivas del núcleo a un supuesto `next`.
## 9. Estado, fases y resultados
La posición separa configuración inmutable, participantes, datos del juego, flujo y resultado. El sobre persistido añade versiones fijadas, revisión global, época de fase y revisiones por actor. Los identificadores `p0`, `p1`, etc. representan asientos; el vínculo con cuentas pertenece a la plataforma.
El flujo contiene fase, ronda, turno y `actors`, un mapa de asiento a etapa. Las fases declaran participación `single`, `multiple` o `automatic`; sus etapas enumeran tipos de acción permitidos. El núcleo valida esas referencias y que los actores sigan activos. `single` admite exactamente un actor en posiciones activas estables; `multiple`, los actores habilitados; `automatic`, ninguno. Un estado terminal no tiene actores.
Las reglas definen cuándo aumentan ronda y turno; no disminuyen dentro de la partida. No hay un `currentSeat` adicional que pueda contradecir a `actors`.
El resultado es `null` mientras la partida está activa. Al completarse, incluye motivo y un resultado por participante: `win`, `loss`, `draw` o `neutral`, con puntuación, puesto, equipo y métricas opcionales. En el estado privado figuran todos los participantes, incluidos los retirados. La proyección puede omitir métricas o equipos secretos, conservando la coherencia del resultado público.
```json
{
"kind": "completed",
"reasonCode": "objective.failed",
"participants": {
"p0": { "outcome": "loss", "metrics": {} },
"p1": { "outcome": "loss", "metrics": {} }
}
}
```
Ese resultado representa una derrota cooperativa. Un Solitario perdido tiene únicamente `p0: loss`. Se permiten ganadores compartidos, empates y clasificaciones. La plataforma no declara ganador al último participante por una regla universal.
Una cancelación usa `{ kind: cancelled, reasonCode }` y no inventa ganadores. La transición a terminal se confirma una sola vez, cancela plazos pendientes y deja de ofrecer acciones.
## 10. Concurrencia sin conflictos innecesarios
Todo cambio aceptado incrementa `revision` exactamente una vez. La creación produce revisión 0; rechazos y lecturas no aumentan esa revisión. Incluso una actualización privada genera una nueva revisión observable, aunque algunas vistas no cambien.
| Política | Precondición | Uso |
| -------- | -------------------------------------------- | --------------------------------------------------------------------------- |
| `match` | `{ kind: match, revision }` | Jugadas que dependen del estado compartido: disparar, tirar, mover o robar. |
| `actor` | `{ kind: actor, phaseEpoch, actorRevision }` | Operaciones independientes sobre la preparación privada del actor. |
Con `actor`, un cambio privado de p1 no invalida automáticamente la colocación pendiente de p0. El servidor vuelve a validar la acción contra el estado actual bajo el bloqueo; no aplica ciegamente un parche construido sobre datos antiguos.
Las reglas de actualización de contadores son obligatorias:
1. Una acción `actor` incrementa la revisión de ese actor.
2. Una acción `match`, un evento de ciclo de vida o un cambio de fase, ronda, turno o resultado incrementan `phaseEpoch` y todas las revisiones de actor.
3. Dentro de una fase de preparación, cambiar la etapa o disponibilidad del propio actor mantiene la independencia. Cambiar la habilitación de otro actor exige una barrera común y el incremento de época.
4. El paso de colocación a batalla de Hundido es una barrera. Un comando tardío de colocación queda obsoleto aunque su revisión privada coincidiera.
El módulo debe justificar `actor` mediante pruebas de independencia y confluencia: ejecutar acciones válidas de jugadores distintos en ambos órdenes da el mismo resultado lógico, salvo metadatos de orden. La última preparación puede desencadenar la transición común una vez cumplida la condición global.
El núcleo no puede deducir qué campos lee una función JavaScript arbitraria. Por eso `actor` es una capacidad explícita probada por el juego, no una optimización automática. Las acciones sin esa garantía usan `match`. Las revisiones no ocultan el hecho de que hubo actividad; los juegos que necesiten ocultar incluso tiempos o frecuencia exigirán otro perfil de transporte.
## 11. Comandos, recibos y transacción
Un comando lleva protocolo, partida, UUID de comando, precondición y acción. El cliente no envía actor, identidad autenticada, resultado, azar ni nueva revisión. La plataforma deriva el actor de la sesión y del asiento autorizado.
La identidad idempotente es `(matchId, principal autenticado, commandId)`. Su huella incluye el sobre validado completo, también la precondición, serializado como JSON canónico. Cambiar un campo con el mismo UUID produce `COMMAND_ID_REUSED` y no reemplaza el recibo original.
La canonicalización de comandos usa también JCS. Los decodificadores de JSON de entrada deben rechazar claves duplicadas y Unicode inválido antes de perder esa información durante el parseo; los validadores de objetos adjuntos no detectan por sí solos claves duplicadas del texto original.
El procesamiento es:
1. Validar tamaño y sobre; autenticar y autorizar la pertenencia.
2. Bloquear la partida y resolver su módulo exacto.
3. Buscar recibo previo antes de volver a evaluar fase o final de partida.
4. Validar precondiciones, etapa, tipo y parámetros; ejecutar reglas y validar la posición resultante.
5. Construir y validar las proyecciones, eventos visibles y acciones ofrecidas para los destinatarios autorizados.
6. Confirmar en una transacción estado, revisión, recibo, eventos internos, traza de azar, plazos y publicaciones pendientes.
7. Enviar el recibo y despachar las publicaciones tras el commit.
La publicación pendiente es una outbox duradera y ordenada por partida. Contiene la proyección necesaria para publicar cada revisión, no una lectura posterior del estado que mezcle revisiones. El envío revalida que el destinatario conserve acceso. Un fallo de envío se reintenta; no revierte una jugada ya confirmada.
Se guardan recibos de aceptación y rechazos de negocio definitivos: revisión o época obsoletas, acción ilegal o partida terminada. Errores transitorios de infraestructura y límites de tráfico usan `game.error`; no consumen el comando con un rechazo permanente.
Una repetición idéntica devuelve el mismo resultado con `duplicate: true`, aunque la partida ya haya terminado. Una intención corregida usa un UUID nuevo. Los recibos se conservan durante toda la retención de la partida; no caducan antes que sus datos.
El cliente conserva la intención pendiente por cuenta y partida hasta aclarar el resultado. Reintentar transporte significa reenviar exactamente el mismo sobre. `game.command-status` consulta un recibo; `game.command-unknown` no prueba que un envío retrasado nunca llegará. Ante incertidumbre se reintenta el mismo UUID.
PostgreSQL proporciona el bloqueo y la transacción de producción. Un almacén de archivos solo puede ofrecer el perfil de desarrollo de un proceso, escribiendo atómicamente el conjunto completo. Varias instancias requieren además coordinación del despachador y las suscripciones; no se habilitan solo añadiendo réplicas de Node.
## 12. Protocolo y reconexión
Los sobres completos están en [protocol.schema.json](game-engine/protocol.schema.json) y [wire.json](game-engine/examples/wire.json).
| Mensaje | Dirección y finalidad |
| ---------------------- | ------------------------------------------------------------------------------------------------ |
| `game.sync` | Cliente → servidor: negociar contrato y establecer una suscripción nueva. |
| `game.snapshot` | Servidor → cliente: vista completa autorizada, contexto, acciones, resultado y eventos visibles. |
| `game.command` | Cliente → servidor: intención con precondición. |
| `game.ack` | Servidor → cliente: recibo aplicado o rechazado. |
| `game.command-status` | Cliente → servidor: consultar una intención pendiente. |
| `game.command-unknown` | Servidor → cliente: no hay recibo registrado en ese momento. |
| `game.cursor` | Servidor → cliente: última revisión confirmada para comprobar continuidad. |
| `game.unsubscribe` | Cliente → servidor: cerrar esa suscripción. |
| `game.error` | Servidor → cliente: error y recuperación prevista. |
Se envían snapshots completos en v1. Los deltas quedan fuera del primer refactor: primero se mide el coste real. El contexto es una proyección explícita, nunca el flujo o la configuración privados copiados automáticamente.
### Barrera de sincronización
Leer el estado y suscribirse después deja una ventana en la que puede perderse una jugada. La implementación debe:
1. Autorizar, negociar y crear un `subscriptionId`; instalar primero la recepción de publicaciones y almacenarlas temporalmente.
2. Leer una posición confirmada de revisión R.
3. Enviar el snapshot de sincronización con R, el `requestId` correspondiente y `events: []`.
4. Descartar publicaciones almacenadas hasta R y entregar las posteriores en orden.
5. Si hay un hueco o se supera el límite del buffer, generar una nueva sincronización, sin presentar una secuencia incompleta.
El cliente solo acepta la respuesta de su solicitud de sincronización más reciente y mensajes de esa suscripción. El token es propio de esa conexión; el servidor autoriza también la baja para que otra conexión no pueda cancelarla.
Un snapshot vivo declara `fromRevision` y contiene exactamente la revisión siguiente. Los eventos visibles tienen UUID estable por proyección, revisión y orden local consecutivo. Un evento interno puede desaparecer o producir varios eventos visibles; no se filtra su índice privado original.
Duplicados se descartan. Un salto solicita sincronización. La sincronización inicial, una reconexión y una recuperación muestran el estado actual sin reproducir eventos históricos ni sonidos.
Para detectar la pérdida de la última publicación, cuando no llega otra que revele el hueco, el servidor envía un cursor de revisión periódicamente. La frecuencia inicial propuesta es 15 segundos. Un cursor superior a la revisión confirmada, volver al primer plano o un recibo aplicado cuya vista no llega activan recuperación acotada. El cursor es una lectura de progreso, no un sustituto de la vista.
Los recibos pueden llegar antes o después de la vista. Un recibo no cambia el tablero por sí solo. La vista confirmada es la autoridad visual; el cliente mantiene separadamente el estado del comando pendiente.
### Errores
`game.error` incluye código, clave traducible y una recuperación entre `none`, `sync`, `retry-same-command`, `reload-client` y `reauthenticate`. Un error de mensaje que aún no identifica una partida válida puede omitir `matchId`.
Los errores no incluyen estado privado, trazas internas ni mensajes de excepción. Ofrecer una acción no garantiza que siga siendo legal al ejecutarla: siempre se valida en el servidor.
## 13. Presentación antes, durante y después
El cliente mantiene tres elementos diferentes: última proyección confirmada, proyección mostrada y plan de transición local. Una vista recién recibida actualiza inmediatamente la confirmada; la mostrada puede seguir en el estado anterior mientras se representa el cambio.
La presentación implementa `mount`, un renderer con `update/dispose`, `buildTransitionPlan` y un registro de efectos. Recibe solo proyecciones autorizadas. Las acciones disponibles se presentan junto con la vista correspondiente; el coordinador impide enviar una intención cuando el cliente está desactualizado o bloqueado.
`buildTransitionPlan` es puro. Construye un grafo acíclico de pasos con efectos registrados, parámetros validados, dependencias, marcadores, indicación de bloqueo y timeout. Un disparador `after` con lista vacía inicia un paso; con dependencias espera a que terminen. Un disparador `marker` comienza al producirse ese marcador de un paso declarado.
El plan de [caída de ficha](game-engine/examples/drop.plan.json) demuestra movimiento, sonido en el impacto y resaltado posterior de una victoria. El renderer puede mantener una representación de la ficha en tránsito sin modificar el resultado confirmado.
### Un solo responsable del cierre
| Momento | Coordinador | Efecto |
| ------------ | ------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| Antes | Congela las proyecciones de origen y destino, valida el plan y prepara recursos. | `prepare(params, context)` crea un handle cancelable. |
| Durante | Programa dependencias, marcadores y pistas paralelas. | `play()` representa el efecto y resuelve al terminar. |
| Después | Ejecuta `settle` una vez cuando terminan los pasos bloqueantes; instala la proyección de destino. | `dispose(reason)` libera recursos y listeners. |
| Interrupción | Aborta la generación, invalida callbacks y recupera una vista coherente. | Detiene actividad y libera el handle. |
`settle` pertenece exclusivamente al coordinador. No aparece en la interfaz de cada efecto: terminar un sonido o una pista no termina toda la transición.
Cada handle recibe una única llamada de limpieza por el coordinador y su limpieza debe tolerar repetición defensiva. Si una preparación termina después de cancelarse, su handle se libera inmediatamente. Todos los callbacks comprueban `AbortSignal` y el identificador de generación antes de tocar el frame, emitir marcadores o reproducir audio.
Un marcador no emitido no deja dependientes esperando indefinidamente: al terminar su productor se omiten esos dependientes y se liberan sus bloqueos. Un fallo o timeout de un paso bloqueante cancela el plan, limpia su cola y muestra la última proyección confirmada. El `finally` del coordinador siempre restablece un estado utilizable.
### Audio, colas y accesibilidad
El audio se sincroniza con marcadores visuales, no con retrasos calculados a partir de relojes entre máquinas. Cada reproducción declara canal, política `mix | replace | skip-if-busy` y retraso máximo tolerado. Un recurso que llega tarde se omite cuando ya perdió su relación con el movimiento.
Los pasos no bloqueantes no retrasan `settle`. Pueden continuar entre planes dentro de su timeout y política de canal, pero conservan su propietario y se limpian al salir, sincronizar o cancelar esa generación. Un navegador que no permite sonido no impide jugar.
Solo se encolan transiciones contiguas. Si la cola se desborda, se limpia y se muestra la última vista confirmada. Mientras haya un comando pendiente, una vista mostrada antigua, desconexión o animación bloqueante, no se admiten nuevos comandos del tablero. Chat, preferencias, omitir animación y salir permanecen accesibles.
Movimiento reducido y animaciones desactivadas resuelven los efectos sin esperar una duración visual ficticia. Los controles deben admitir teclado, nombres accesibles y una descripción textual del resultado; nunca dependen exclusivamente de color o audio. Una pausa para leer un dado es local, omitible y acotada; no exige un comando adicional al servidor.
## 14. Límites operativos iniciales
Estos valores son límites iniciales configurables para la implementación y deben medirse antes de ampliarlos:
| Recurso | Límite inicial |
| -------------------------------------------------- | -------------------------------------------------------------- |
| Comando JSON | 64 KiB y profundidad máxima 32. |
| Datos privados por partida y proyección individual | 1 MiB por documento serializado. |
| Plan | 128 pasos; 12 segundos máximos por paso y por plan bloqueante. |
| Cola de presentación | 16 transiciones. |
| Buffer de sincronización | 128 revisiones y 8 MiB, lo que se alcance primero. |
| Comandos pendientes del tablero | Uno por cliente y partida; duplicados son reintentos. |
| Cursor de revisión | Cada 15 segundos mientras exista suscripción. |
Los esquemas comprueban algunos límites estructurales; el transporte y el runtime deberán imponer bytes, profundidad, tiempo y memoria. Los recursos gráficos grandes viven en el bundle, no en los datos JSON de la partida.
V1 carga módulos de confianza incluidos en el despliegue. No ofrece un sandbox para ejecutar JavaScript arbitrario de usuarios. Las funciones puras y sus límites se comprueban con análisis y pruebas; un plugin no confiable necesitaría aislamiento de proceso y otro modelo de permisos.
## 15. Privacidad y persistencia verificables
La revisión de privacidad abarca vista, contexto, ofertas, resultado, eventos, errores, logs y publicaciones persistidas. No basta con borrar un campo del tablero. Un espectador solo existe si la plataforma lo autoriza y el juego define su proyección; la variante actual puede seguir sin espectadores.
Antes de confirmar, el núcleo valida esquema de datos, invariantes, flujo, resultado y esquemas de todas las salidas públicas. Se conservan datos internos para recuperar la partida y auditar comandos; nunca se entregan como snapshot al navegador.
La reproducción usa configuración, participantes, módulo fijado, comandos y eventos de ciclo de vida, instante capturado y traza de azar. Debe obtener los mismos datos, flujo y resultado. Los UUID y tiempos de transporte no forman parte de esa comparación lógica.
La observabilidad registra identificadores de partida, comando, revisión, duración, tipo de rechazo, tamaño de cola y fallos de entrega. No registra por defecto manos privadas, contraseñas ni payloads completos. Las métricas permiten distinguir un fallo de proxy de una suscripción perdida, un error de reglas o un efecto visual atascado.
## 16. Compilación y aceptación de un juego
El futuro comando de publicación deberá ejecutar en orden:
1. Validación estructural del manifiesto y resolución exacta y acíclica de dependencias.
2. Validación de archivos, exportaciones, tipos de componentes, extensiones y asociaciones visuales.
3. Cobertura de ilustraciones, regiones de atlas, recursos de catálogo y traducciones.
4. Compilación separada de servidor y cliente; correspondencia de identificadores, versiones, fases y acciones con el manifiesto.
5. Validación de los esquemas específicos del juego y pruebas de reglas, invariantes, azar y privacidad.
6. Pruebas de conformidad del protocolo y la presentación.
7. Cálculo del inventario y lock, publicación inmutable y comprobación de carga.
Las licencias y atribuciones de recursos deben quedar en los paquetes que los distribuyen. El valor `UNLICENSED` de los ejemplos es metadato de la especificación, no una afirmación de licencia sobre ilustraciones de terceros.
No se considera conforme un juego que solo tenga un manifiesto válido: debe superar también sus pruebas de comportamiento y poder cargarse desde su distribución final.
## 17. Migración y criterios de aceptación
Primero se implementan núcleo y adaptador de compatibilidad; después se migra Conecta 4, que valida el flujo público sencillo. Hundido valida preparación privada y simultánea. Brisca valida cartas, zonas y eventos ocultos. Oca y Serpientes validan azar y secuencias visuales. Los demás juegos conservan sus reglas actuales durante la migración gradual.
Solitario es aquí un caso de diseño y conformidad, no un nuevo juego disponible en el catálogo. Un juego de rol es un perfil futuro que deberá concretar sus reglas y pruebas antes de incorporarse.
Cada migración conserva fixtures de partidas actuales, compara reglas y proyecciones y permite mantener partidas antiguas con su módulo fijado. El protocolo anterior necesita un adaptador explícito durante la transición; no se cambian nombres de eventos en caliente suponiendo que todos los navegadores se actualizan a la vez.
| Caso obligatorio | Resultado esperado |
| ---------------------------------------------------------- | ---------------------------------------------------------------------- |
| Victoria, empate, derrota individual y derrota cooperativa | Resultado correcto por participante. |
| Retirada en preparación, turno y fase cooperativa | Política del módulo; sin victoria universal inventada. |
| Dos colocaciones privadas simultáneas | Ambas válidas si son independientes; sin conflicto global innecesario. |
| Colocación tardía al empezar batalla | Rechazo por época o fase obsoleta. |
| Comando repetido antes y después del final | Un solo cambio; mismo recibo. |
| Mismo UUID con otro payload o precondición | Error sin modificar el recibo original. |
| Caída después del commit y antes del envío | Recuperación desde recibo y outbox, sin repetir reglas. |
| Cambio durante sincronización | Estado leído o publicación posterior, sin ventana perdida. |
| Pérdida de la última publicación | Cursor provoca resincronización. |
| Evento oculto | Ninguna filtración en vista, contexto, ofertas, eventos o resultado. |
| Cancelación mientras carga un efecto | Handle tardío liberado; sin sonido o frame antiguo. |
| Animaciones o sonido desactivados | Transición finaliza y controles recuperados. |
| Falta temporal de un recurso | Fallback declarado, sin bloqueo de reglas. |
| Dos copias de una baraja | Identidad distinta y pertenencia única a zonas. |
| Reinicio con plazos pendientes | Una ejecución por generación válida. |
| Reproducción de partida | Mismo estado lógico con el módulo y la traza fijados. |
Estos casos son requisitos de la futura implementación. Las comprobaciones actuales no simulan todavía PostgreSQL, sockets ni un navegador.
## 18. Verificación disponible ahora
Desde la raíz del repositorio, con las dependencias actuales:
```powershell
node docs/game-engine/verify.ts
.\node_modules\.bin\tsc.cmd --project docs/game-engine/tsconfig.json
```
Para regenerar los esquemas después de cambiar su fuente:
```powershell
node docs/game-engine/verify.ts --write-schemas
```
El verificador valida los ejemplos, rechaza casos incoherentes y detecta diferencias entre los esquemas generados y su fuente. El proyecto TypeScript aislado comprueba contratos y errores esperados sin importar estos archivos en la aplicación.
Quedan como trabajo de implementación el compilador de paquetes, el SDK ejecutable, el núcleo transaccional, el coordinador visual, las pruebas de integración y la migración de los juegos. Esta separación permite revisar y verificar el modelo antes de comprometer datos o producción.

@ -0,0 +1,324 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "deck"
},
"schemaVersion": {
"type": "number",
"const": 1
},
"cards": {
"minItems": 1,
"type": "array",
"items": {
"$ref": "#/$defs/__schema0"
}
}
},
"required": ["kind", "schemaVersion", "cards"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "dice"
},
"schemaVersion": {
"type": "number",
"const": 1
},
"dice": {
"minItems": 1,
"type": "array",
"items": {
"$ref": "#/$defs/__schema4"
}
}
},
"required": ["kind", "schemaVersion", "dice"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "wheel"
},
"schemaVersion": {
"type": "number",
"const": 1
},
"sectors": {
"minItems": 1,
"type": "array",
"items": {
"$ref": "#/$defs/__schema6"
}
}
},
"required": ["kind", "schemaVersion", "sectors"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "board"
},
"schemaVersion": {
"type": "number",
"const": 1
},
"layout": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "grid"
},
"rows": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"columns": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"required": ["kind", "rows", "columns"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "graph"
},
"cells": {
"minItems": 1,
"type": "array",
"items": {
"$ref": "#/$defs/__schema7"
}
}
},
"required": ["kind", "cells"],
"additionalProperties": false
}
]
}
},
"required": ["kind", "schemaVersion", "layout"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "pieces"
},
"schemaVersion": {
"type": "number",
"const": 1
},
"definitions": {
"minItems": 1,
"type": "array",
"items": {
"$ref": "#/$defs/__schema8"
}
}
},
"required": ["kind", "schemaVersion", "definitions"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "custom"
},
"schemaVersion": {
"type": "number",
"const": 1
},
"typeId": {
"$ref": "#/$defs/__schema1"
},
"data": {
"$ref": "#/$defs/__schema2"
}
},
"required": ["kind", "schemaVersion", "typeId", "data"],
"additionalProperties": false
}
],
"$defs": {
"__schema0": {
"type": "object",
"properties": {
"id": {
"$ref": "#/$defs/__schema1"
},
"suit": {
"$ref": "#/$defs/__schema1"
},
"rank": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991
},
"attributes": {
"$ref": "#/$defs/__schema2"
}
},
"required": ["id", "suit", "rank", "attributes"],
"additionalProperties": false
},
"__schema1": {
"type": "string",
"pattern": "^[a-z][a-zA-Z0-9.-]{0,79}$"
},
"__schema2": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"$ref": "#/$defs/__schema3"
}
},
"__schema3": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"type": "array",
"items": {
"$ref": "#/$defs/__schema3"
}
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"$ref": "#/$defs/__schema3"
}
}
]
},
"__schema4": {
"type": "object",
"properties": {
"id": {
"$ref": "#/$defs/__schema1"
},
"faces": {
"minItems": 2,
"type": "array",
"items": {
"$ref": "#/$defs/__schema5"
}
}
},
"required": ["id", "faces"],
"additionalProperties": false
},
"__schema5": {
"type": "object",
"properties": {
"id": {
"$ref": "#/$defs/__schema1"
},
"value": {
"$ref": "#/$defs/__schema3"
},
"weight": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 1000000
}
},
"required": ["id", "value", "weight"],
"additionalProperties": false
},
"__schema6": {
"type": "object",
"properties": {
"id": {
"$ref": "#/$defs/__schema1"
},
"weight": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 1000000
}
},
"required": ["id", "weight"],
"additionalProperties": false
},
"__schema7": {
"type": "object",
"properties": {
"id": {
"$ref": "#/$defs/__schema1"
},
"neighbors": {
"type": "array",
"items": {
"$ref": "#/$defs/__schema1"
}
}
},
"required": ["id", "neighbors"],
"additionalProperties": false
},
"__schema8": {
"type": "object",
"properties": {
"id": {
"$ref": "#/$defs/__schema1"
},
"count": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"attributes": {
"$ref": "#/$defs/__schema2"
}
},
"required": ["id", "count", "attributes"],
"additionalProperties": false
}
},
"$id": "urn:juegoland:component:1"
}

@ -0,0 +1,310 @@
// SDK contract for the proposed engine. Types only; no production runtime.
import type { z } from 'zod';
import type {
action,
json,
lockSchema,
presentationPlanSchema,
MatchOutcome,
GamePackage,
publicEvent,
ActionOffer,
} from './schema.ts';
export type Json = z.infer<typeof json>;
export type JsonObject = { [key: string]: Json };
export type SeatId = `p${number}`;
export type DeepReadonly<T> = T extends (...args: never[]) => unknown
? T
: T extends readonly (infer V)[]
? readonly DeepReadonly<V>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
export interface RuntimeSchema<T> {
parse(input: unknown): T;
}
export type GameAction = z.infer<typeof action>;
export type DomainEvent = { type: string; payload: JsonObject };
export type VisibleEvent = DomainEvent;
export type DeliveredEvent = z.infer<typeof publicEvent>;
export interface Participant {
seat: SeatId;
teamId: string | null;
role: string;
status: 'active' | 'withdrawn';
}
export interface Flow {
phase: string;
round: number;
turn: number;
actors: Partial<Record<SeatId, string>>; // seat -> stage
}
export interface PhaseDefinition {
participation: 'single' | 'multiple' | 'automatic';
stages: Readonly<Record<string, readonly string[]>>; // stage -> action types
}
export interface Position<Config, Data> {
matchId: string;
config: Config;
participants: Participant[];
data: Data;
flow: Flow;
outcome: MatchOutcome | null;
}
export type Recipient =
{ kind: 'participant'; seat: SeatId; role: string } | { kind: 'observer' }; // Only when the platform explicitly authorizes it.
export interface CardDefinition {
id: string;
suit: string;
rank: number;
attributes: JsonObject;
}
export interface CardInstance {
id: string;
definitionId: string;
copy: number;
componentId: string;
}
export interface Zone {
id: string;
owner: SeatId | null;
ordered: boolean;
visibility: 'public' | 'owner' | 'hidden';
items: string[];
}
export interface DeckState {
instances: CardInstance[];
zones: Zone[];
}
export interface DieFace {
id: string;
value: Json;
weight: number;
}
export interface WheelSector {
id: string;
weight: number;
}
export interface RuleServices {
readonly nowMs: number; // Captured once and recorded with this command.
readonly definitions: DeepReadonly<Record<string, Json>>;
readonly random: {
int(tag: string, minInclusive: number, maxExclusive: number): number;
shuffle<T extends Json>(tag: string, items: readonly T[]): T[];
weighted(
tag: string,
options: readonly { id: string; weight: number }[],
): string;
};
readonly cards: {
instantiate(
componentId: string,
definitions: readonly CardDefinition[],
copies: number,
): DeckState;
move(
state: DeepReadonly<DeckState>,
ids: readonly string[],
source: string,
destination: string,
index: number,
): DeckState;
};
}
export type DeadlineIntent =
| { type: 'schedule'; id: string; dueAtMs: number; payload: JsonObject }
| { type: 'cancel'; id: string };
export interface Transition<Data, Event> {
data: Data;
flow: Flow;
outcome: MatchOutcome | null;
events: Event[];
deadlines: DeadlineIntent[];
}
export type RuleResult<Data, Event> =
| { ok: true; transition: Transition<Data, Event> }
| { ok: false; code: 'ILLEGAL_ACTION' };
export interface ActionHandler<Config, Data, Action, Event> {
schema: RuntimeSchema<Action>;
conflictPolicy: 'match' | 'actor';
apply(
position: DeepReadonly<Position<Config, Data>>,
command: DeepReadonly<{ actor: SeatId; action: Action }>,
services: RuleServices,
): RuleResult<Data, Event>;
}
export type OfferDraft<Action extends GameAction> =
| { kind: 'concrete'; action: Action }
| {
kind: 'parameterized';
actionType: Action['type'];
inputSchemaId: string;
constraints: JsonObject;
};
export interface Projection<View> {
view: View;
context: JsonObject; // Explicit projection; never the raw Flow or config.
outcome: MatchOutcome | null;
}
export type LifecycleEvent =
| { type: 'participant.withdrawn'; seat: SeatId }
| { type: 'deadline.reached'; deadlineId: string; payload: JsonObject };
export interface GameDefinition<
Config,
Data,
View,
Action extends GameAction,
Event extends DomainEvent,
PublicEvent extends VisibleEvent,
> {
id: string;
rulesVersion: string;
schemas: {
config: RuntimeSchema<Config>;
data: RuntimeSchema<Data>;
view: RuntimeSchema<View>;
context: RuntimeSchema<JsonObject>;
event: RuntimeSchema<Event>;
visibleEvent: RuntimeSchema<PublicEvent>;
};
phases: Readonly<Record<string, PhaseDefinition>>;
setup(
input: DeepReadonly<{
matchId: string;
config: Config;
participants: Participant[];
}>,
services: RuleServices,
): Transition<Data, Event>;
validatePosition(position: DeepReadonly<Position<Config, Data>>): void;
actions: {
[K in Action['type']]: ActionHandler<
Config,
Data,
Extract<Action, { type: K }>,
Event
>;
};
availableActions(
position: DeepReadonly<Position<Config, Data>>,
recipient: DeepReadonly<Recipient>,
): OfferDraft<Action>[];
project(
position: DeepReadonly<Position<Config, Data>>,
recipient: DeepReadonly<Recipient>,
): Projection<View>;
projectEvent(
input: DeepReadonly<{
before: Position<Config, Data> | null;
after: Position<Config, Data>;
event: Event;
recipient: Recipient;
}>,
): PublicEvent[];
onLifecycle(
position: DeepReadonly<Position<Config, Data>>,
event: DeepReadonly<LifecycleEvent>,
services: RuleServices,
): Transition<Data, Event>;
}
export interface PersistedEnvelope<Config, Data> {
schemaVersion: 1;
game: {
id: string;
rulesVersion: string;
stateSchemaVersion: number;
packageDigest: string;
dependencyLockDigest: string;
};
revision: number;
phaseEpoch: number;
actorRevisions: Partial<Record<SeatId, number>>;
position: Position<Config, Data>;
}
export type PackageLock = z.infer<typeof lockSchema>;
export type PresentationPlan = z.infer<typeof presentationPlanSchema>;
export type PresentationStep = PresentationPlan['steps'][number];
export type StepTrigger = PresentationStep['trigger'];
export interface Preferences {
reducedMotion: boolean;
sound: boolean;
volume: number;
announcements: boolean;
}
// The module at a `graphic` resource path exports this definition as default.
// Renderers own insertion and removal; a graphic creates no timers or commands.
export interface GraphicDefinition {
render(
input: DeepReadonly<{
resourceId: string;
slotId: string | null;
definition: Json | null;
locale: string;
}>,
): SVGElement;
}
export type FinishReason =
'completed' | 'skipped' | 'cancelled' | 'failed' | 'timeout';
export interface EffectContext {
signal: AbortSignal;
preferences: DeepReadonly<Preferences>;
resource(id: string): Promise<unknown>;
frame(key: string, value: Json): void;
marker(name: string): void;
audio(input: {
resource: string;
channel: string;
policy: 'mix' | 'replace' | 'skip-if-busy';
maxLateMs: number;
}): void;
}
export interface EffectHandle {
play(): Promise<void>;
dispose(reason: FinishReason): void;
}
export interface EffectDefinition {
params: RuntimeSchema<JsonObject>;
prepare(
input: DeepReadonly<JsonObject>,
context: EffectContext,
): Promise<EffectHandle>;
}
export interface RenderFrame<View> {
presented: Projection<View>;
availableActions: ActionOffer[];
presentation: JsonObject;
busy: boolean;
connected: boolean;
}
export interface Renderer<View> {
update(frame: DeepReadonly<RenderFrame<View>>): void;
dispose(): void;
}
export interface PresentationDefinition<
View,
Action extends GameAction,
PublicEvent extends VisibleEvent,
> {
apiVersion: 1;
mount(
host: HTMLElement,
frame: DeepReadonly<RenderFrame<View>>,
intent: (action: Action) => void,
): Renderer<View>;
buildTransitionPlan(
input: DeepReadonly<{
from: Projection<View>;
to: Projection<View>;
fromRevision: number;
toRevision: number;
events: (PublicEvent & { eventId: string })[];
manifest: GamePackage;
preferences: Preferences;
}>,
): PresentationPlan;
effects: Readonly<Record<string, EffectDefinition>>;
}

@ -0,0 +1,91 @@
# Ejemplos y perfiles de conformidad
Estos archivos acompañan a la [especificación v1](../../game-engine-proposal.md). Son datos completos para los esquemas comunes y decisiones de diseño para las reglas. **No son paquetes instalables ni incorporan juegos nuevos al catálogo.** Las rutas de módulos, esquemas particulares y recursos describen la distribución futura; esos archivos no se han creado bajo estas rutas.
## Qué se puede verificar ahora
| Archivo | Contenido |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| [conecta4.game.json](conecta4.game.json) | Manifiesto de juego público por turnos, tablero, piezas, catálogo y sonido. |
| [hundido.game.json](hundido.game.json) | Manifiesto de juego privado con preparación simultánea, flota, atlas y audio. |
| [solitario.game.json](solitario.game.json) | Manifiesto de un solitario que referencia definición de baraja e ilustraciones en bibliotecas distintas. |
| [libraries.json](libraries.json) | Bibliotecas `playing-cards` y `cards-poker`, ambas con versión exacta. |
| [components.json](components.json) | Cuadrícula, las 52 definiciones de una baraja de póquer, dado, ruleta y piezas. |
| [poker.resource-set.json](poker.resource-set.json) | Asociación de las 52 caras y reverso a gráficos reutilizables; no contiene imágenes nuevas. |
| [wire.json](wire.json) | Sobres válidos de sincronización, comandos, recibos, resultados, cursores y errores. |
| [drop.plan.json](drop.plan.json) | Grafo de efectos de una caída ganadora de Conecta 4, con sonido al impacto. |
El verificador comprueba manifiestos, referencias entre bibliotecas, componentes, cobertura de las 52 caras, plan y sobres. Los ejemplos de red son casos independientes, **no una sesión completa**. La vista `{ "example": "wire-envelope-only" }` verifica únicamente el sobre: una partida real necesita pasar además los esquemas `view` y `context` del juego correspondiente.
Los gráficos `graphic` se implementarán mediante `GraphicDefinition`: generan un SVG nuevo a partir del recurso, slot, definición e idioma. Por eso un mismo módulo puede representar las 52 caras. El recurso de catálogo también recibe su identificador y distingue portada, miniatura e icono. El código gráfico no modifica reglas ni programa efectos.
En Hundido, el atlas usa las dimensiones y regiones del recurso naval existente como referencia para su futura distribución. En Solitario existe ya una definición compartida de cartas en la aplicación, pero estas bibliotecas empaquetadas y sus gráficos de póquer siguen siendo trabajo futuro.
## Perfil 1: Conecta 4
Objetivo: validar el circuito mínimo completo de una acción pública.
| Elemento | Contrato |
| ------------- | --------------------------------------------------------------------------------------------------------- |
| Configuración | 6 filas, 7 columnas, objetivo de 4; otras variantes exigirían validación explícita. |
| Datos | Cuadrícula, número de jugadas y último movimiento. Las casillas contienen asiento o vacío. |
| Flujo | Fase `playing`, participación `single`, etapa `move`; un actor habilitado. |
| Acción | `drop`, payload `{ column }`, entero entre 0 y 6; política `match`. |
| Validación | Actor correcto, columna no llena, partida activa. |
| Transición | Ocupa la primera fila libre, evalúa línea y tablero lleno; después cambia actor si continúa. |
| Evento | `disc.dropped`, con fila, columna y asiento visibles; victoria con casillas de la línea. |
| Resultado | Ganador y perdedor, o ambos con `draw`. |
| Retirada | En una partida activa de dos jugadores, pérdida del que se retira y victoria del rival según este módulo. |
| Presentación | Caída → marcador `impact` y sonido → resaltado de línea solo cuando existe victoria. |
La posición valida dimensiones, gravedad de las fichas, cantidades, último movimiento y coherencia entre resultado y flujo. `availableActions` devuelve una acción concreta por columna libre.
El plan de ejemplo incluye una línea ganadora; una jugada ordinaria omite ese paso. El recurso sonoro es opcional y no bloquea el turno.
## Perfil 2: Hundido
Objetivo: probar privacidad y operaciones independientes antes de una fase compartida.
- `placement` tiene participación `multiple`; cada jugador habilitado está en etapa `arrange`.
- `placeShip` y `ready` usan política `actor`. Una operación solo altera la flota y preparación de su autor, salvo la barrera que inicia batalla cuando ambos han terminado.
- La preparación completa valida límites, tamaños, solapes y la política de adyacencia que se fije en la configuración. No se deja implícita una variante naval.
- `battle` tiene participación `single`, etapa `fire`; `fire({ row, column })` usa política `match`. El disparo valida límites y que esa celda no se haya atacado.
- La regla de conservar o pasar turno tras un acierto forma parte de la configuración validada y de las pruebas del módulo.
- La proyección incluye flota propia, impactos recibidos y conocimiento autorizado del tablero rival. No incluye flota rival no descubierta, ni coordenadas en eventos de preparación.
- Un rival puede saber que hubo actividad por las revisiones, pero no qué barco se colocó. Ocultar también la actividad no es objetivo del perfil v1.
- La retirada durante preparación cancela sin ganador; durante batalla aplica derrota del retirado. Esta es la política propuesta para este perfil, pendiente de contrastar con la variante actual antes de migrar.
- La última flota destruida completa el resultado. Sonido de agua, explosión y hundimiento se eligen desde eventos visibles, nunca inspeccionando estado oculto.
Caso decisivo: p0 y p1 preparan con la misma época y sus revisiones privadas. Una colocación de p0 aumenta la revisión global y la privada de p0; la de p1 sigue siendo válida. Cuando `ready` inicia batalla, cambia la época: ningún comando antiguo puede volver a colocar barcos.
## Perfil 3: Solitario Klondike
Objetivo: probar un participante, instancias de cartas y derrota sin ganador.
- Configuración propuesta inicial: robo de una carta, sin límite de vueltas al mazo. La variante debe quedar fijada al crear la partida.
- `mainDeck` contiene una copia de `poker52`: 52 instancias en zonas de mazo, descarte, siete columnas y cuatro bases.
- La preparación reparte 1–7 cartas a las columnas y mantiene explícitamente orientación y conocimiento de cada carta. Estar en una columna pública no revela cartas boca abajo.
- Fase `playing`, participación `single`, etapa `move`. Acciones propuestas: `draw`, `recycle`, `moveStack`, `moveToFoundation` y `giveUp`, todas con política `match`.
- Las bases crecen desde as a rey por palo; las columnas decrecen alternando color y los huecos admiten rey. Se valida toda la secuencia movida.
- Ganar exige las 52 cartas en bases. Rendirse produce `p0: loss`. No se declara derrota por una comprobación ingenua de «sin movimientos» cuando aún cabe reciclar; resolver matemáticamente la posición no forma parte de v1.
- Se ofrecen movimientos parametrizados para evitar listar combinaciones arbitrarias. El servidor vuelve a comprobar origen, destino, secuencia y visibilidad.
- La proyección oculta orden del mazo y caras cubiertas. El cliente no recibe identidades con las que rastrear cartas ocultas.
- El arte proviene de `cards-poker/standard`; la regla depende solo de `playing-cards/poker52`.
Una partida perdida debe terminar sin asignar una victoria ficticia a su único participante. El fixture de resultado y su variante cooperativa comprueban esta posibilidad a nivel de protocolo.
## Perfil 4: Brisca y juegos con azar animado
Brisca comprobará la baraja española, reglas propias de valor y orden, reparto, manos privadas, triunfo, bazas y descarte. Cada jugador recibe sus cartas y cantidades de las manos ajenas. Un evento de robo privado no puede revelar la cara a los demás. Los resultados exactos de la variante actual se conservarán mediante fixtures antes de migrarla.
Oca y Serpientes comprobarán el ciclo tirada → lectura opcional → recorrido → efecto de casilla → cierre. El servidor ya habrá resuelto todo el movimiento cuando llegue la vista. Un cambio de pestaña o la cancelación de la animación mostrará el destino confirmado sin volver a tirar.
## Perfil 5: cooperación y rol
Una misión cooperativa puede terminar con todos los participantes en `loss`, varios ganadores o resultados distintos por objetivo. La retirada no convierte automáticamente al último jugador en ganador; el módulo decide si la misión sigue, fracasa o se cancela.
Un juego de rol necesitará un paquete concreto que declare personajes, atributos, inventarios, acciones, visibilidad por rol y las reglas de tiradas. Los dados pueden usar componentes comunes; mapas, retratos, objetos y audio se declaran como recursos locales o bibliotecas versionadas. No se ha inventado un sistema universal de rol ni se ha creado un manifiesto que simule tener reglas que todavía no están definidas.
## Condición para convertir un ejemplo en juego publicable
Completar los archivos referenciados, implementar las funciones del SDK, fijar los esquemas de datos y proyección, aportar todos los recursos requeridos y superar los casos de aceptación de la especificación. Solo entonces el publicador puede generar un lock real y activar ese juego.

@ -0,0 +1,499 @@
[
{
"kind": "board",
"schemaVersion": 1,
"layout": {
"kind": "grid",
"rows": 6,
"columns": 7
}
},
{
"kind": "deck",
"schemaVersion": 1,
"cards": [
{
"id": "picas-1",
"suit": "picas",
"rank": 1,
"attributes": {
"color": "black"
}
},
{
"id": "picas-2",
"suit": "picas",
"rank": 2,
"attributes": {
"color": "black"
}
},
{
"id": "picas-3",
"suit": "picas",
"rank": 3,
"attributes": {
"color": "black"
}
},
{
"id": "picas-4",
"suit": "picas",
"rank": 4,
"attributes": {
"color": "black"
}
},
{
"id": "picas-5",
"suit": "picas",
"rank": 5,
"attributes": {
"color": "black"
}
},
{
"id": "picas-6",
"suit": "picas",
"rank": 6,
"attributes": {
"color": "black"
}
},
{
"id": "picas-7",
"suit": "picas",
"rank": 7,
"attributes": {
"color": "black"
}
},
{
"id": "picas-8",
"suit": "picas",
"rank": 8,
"attributes": {
"color": "black"
}
},
{
"id": "picas-9",
"suit": "picas",
"rank": 9,
"attributes": {
"color": "black"
}
},
{
"id": "picas-10",
"suit": "picas",
"rank": 10,
"attributes": {
"color": "black"
}
},
{
"id": "picas-11",
"suit": "picas",
"rank": 11,
"attributes": {
"color": "black"
}
},
{
"id": "picas-12",
"suit": "picas",
"rank": 12,
"attributes": {
"color": "black"
}
},
{
"id": "picas-13",
"suit": "picas",
"rank": 13,
"attributes": {
"color": "black"
}
},
{
"id": "corazones-1",
"suit": "corazones",
"rank": 1,
"attributes": {
"color": "red"
}
},
{
"id": "corazones-2",
"suit": "corazones",
"rank": 2,
"attributes": {
"color": "red"
}
},
{
"id": "corazones-3",
"suit": "corazones",
"rank": 3,
"attributes": {
"color": "red"
}
},
{
"id": "corazones-4",
"suit": "corazones",
"rank": 4,
"attributes": {
"color": "red"
}
},
{
"id": "corazones-5",
"suit": "corazones",
"rank": 5,
"attributes": {
"color": "red"
}
},
{
"id": "corazones-6",
"suit": "corazones",
"rank": 6,
"attributes": {
"color": "red"
}
},
{
"id": "corazones-7",
"suit": "corazones",
"rank": 7,
"attributes": {
"color": "red"
}
},
{
"id": "corazones-8",
"suit": "corazones",
"rank": 8,
"attributes": {
"color": "red"
}
},
{
"id": "corazones-9",
"suit": "corazones",
"rank": 9,
"attributes": {
"color": "red"
}
},
{
"id": "corazones-10",
"suit": "corazones",
"rank": 10,
"attributes": {
"color": "red"
}
},
{
"id": "corazones-11",
"suit": "corazones",
"rank": 11,
"attributes": {
"color": "red"
}
},
{
"id": "corazones-12",
"suit": "corazones",
"rank": 12,
"attributes": {
"color": "red"
}
},
{
"id": "corazones-13",
"suit": "corazones",
"rank": 13,
"attributes": {
"color": "red"
}
},
{
"id": "diamantes-1",
"suit": "diamantes",
"rank": 1,
"attributes": {
"color": "red"
}
},
{
"id": "diamantes-2",
"suit": "diamantes",
"rank": 2,
"attributes": {
"color": "red"
}
},
{
"id": "diamantes-3",
"suit": "diamantes",
"rank": 3,
"attributes": {
"color": "red"
}
},
{
"id": "diamantes-4",
"suit": "diamantes",
"rank": 4,
"attributes": {
"color": "red"
}
},
{
"id": "diamantes-5",
"suit": "diamantes",
"rank": 5,
"attributes": {
"color": "red"
}
},
{
"id": "diamantes-6",
"suit": "diamantes",
"rank": 6,
"attributes": {
"color": "red"
}
},
{
"id": "diamantes-7",
"suit": "diamantes",
"rank": 7,
"attributes": {
"color": "red"
}
},
{
"id": "diamantes-8",
"suit": "diamantes",
"rank": 8,
"attributes": {
"color": "red"
}
},
{
"id": "diamantes-9",
"suit": "diamantes",
"rank": 9,
"attributes": {
"color": "red"
}
},
{
"id": "diamantes-10",
"suit": "diamantes",
"rank": 10,
"attributes": {
"color": "red"
}
},
{
"id": "diamantes-11",
"suit": "diamantes",
"rank": 11,
"attributes": {
"color": "red"
}
},
{
"id": "diamantes-12",
"suit": "diamantes",
"rank": 12,
"attributes": {
"color": "red"
}
},
{
"id": "diamantes-13",
"suit": "diamantes",
"rank": 13,
"attributes": {
"color": "red"
}
},
{
"id": "treboles-1",
"suit": "treboles",
"rank": 1,
"attributes": {
"color": "black"
}
},
{
"id": "treboles-2",
"suit": "treboles",
"rank": 2,
"attributes": {
"color": "black"
}
},
{
"id": "treboles-3",
"suit": "treboles",
"rank": 3,
"attributes": {
"color": "black"
}
},
{
"id": "treboles-4",
"suit": "treboles",
"rank": 4,
"attributes": {
"color": "black"
}
},
{
"id": "treboles-5",
"suit": "treboles",
"rank": 5,
"attributes": {
"color": "black"
}
},
{
"id": "treboles-6",
"suit": "treboles",
"rank": 6,
"attributes": {
"color": "black"
}
},
{
"id": "treboles-7",
"suit": "treboles",
"rank": 7,
"attributes": {
"color": "black"
}
},
{
"id": "treboles-8",
"suit": "treboles",
"rank": 8,
"attributes": {
"color": "black"
}
},
{
"id": "treboles-9",
"suit": "treboles",
"rank": 9,
"attributes": {
"color": "black"
}
},
{
"id": "treboles-10",
"suit": "treboles",
"rank": 10,
"attributes": {
"color": "black"
}
},
{
"id": "treboles-11",
"suit": "treboles",
"rank": 11,
"attributes": {
"color": "black"
}
},
{
"id": "treboles-12",
"suit": "treboles",
"rank": 12,
"attributes": {
"color": "black"
}
},
{
"id": "treboles-13",
"suit": "treboles",
"rank": 13,
"attributes": {
"color": "black"
}
}
]
},
{
"kind": "dice",
"schemaVersion": 1,
"dice": [
{
"id": "d6",
"faces": [
{
"id": "face-1",
"value": 1,
"weight": 1
},
{
"id": "face-2",
"value": 2,
"weight": 1
},
{
"id": "face-3",
"value": 3,
"weight": 1
},
{
"id": "face-4",
"value": 4,
"weight": 1
},
{
"id": "face-5",
"value": 5,
"weight": 1
},
{
"id": "face-6",
"value": 6,
"weight": 1
}
]
}
]
},
{
"kind": "wheel",
"schemaVersion": 1,
"sectors": [
{
"id": "advance",
"weight": 3
},
{
"id": "wait",
"weight": 1
}
]
},
{
"kind": "pieces",
"schemaVersion": 1,
"definitions": [
{
"id": "disc",
"count": 42,
"attributes": {}
}
]
}
]

@ -0,0 +1,118 @@
{
"format": "juegoland.package",
"formatVersion": 1,
"id": "conecta4",
"version": "1.0.0",
"engineApiVersion": 1,
"metadata": {
"name": "Conecta 4",
"description": "Paquete de referencia de Conecta 4; especificación previa a su implementación.",
"authors": ["Juegoland"],
"license": "UNLICENSED",
"defaultLocale": "es",
"locales": {
"es": "locales/es.json"
}
},
"dependencies": [],
"kind": "game",
"classification": {
"category": "board",
"tags": ["conecta4"],
"competition": "individual"
},
"players": {
"min": 2,
"max": 2
},
"capabilities": {
"hiddenInformation": false,
"simultaneousActions": false,
"deadlines": false
},
"components": {
"board": {
"kind": "board",
"definition": {
"source": "local",
"path": "components/board.json"
},
"copies": 1
},
"discs": {
"kind": "pieces",
"definition": {
"source": "local",
"path": "components/discs.json"
},
"copies": 1
}
},
"rules": {
"entry": "rules/index.ts",
"version": "1.0.0",
"stateSchemaVersion": 1,
"viewSchemaVersion": 1,
"eventSchemaVersion": 1,
"schemas": {
"config": "schemas/config.json",
"data": "schemas/data.json",
"view": "schemas/view.json",
"context": "schemas/context.json",
"event": "schemas/event.json",
"visibleEvent": "schemas/visible-event.json"
}
},
"presentation": {
"entry": "presentation/index.ts",
"apiVersion": 1,
"bundleVersion": "1.0.0",
"resources": {
"catalog.cover": {
"kind": "graphic",
"path": "presentation/catalog.ts",
"load": "catalog",
"required": true,
"fallback": {
"kind": "text",
"key": "catalog.name"
}
},
"catalog.thumbnail": {
"kind": "graphic",
"path": "presentation/catalog.ts",
"load": "catalog",
"required": true,
"fallback": {
"kind": "text",
"key": "catalog.name"
}
},
"catalog.icon": {
"kind": "graphic",
"path": "presentation/catalog.ts",
"load": "catalog",
"required": true,
"fallback": {
"kind": "text",
"key": "catalog.name"
}
},
"sound.drop": {
"kind": "audio",
"path": "assets/connect-four-drop.wav",
"load": "board",
"required": false,
"fallback": {
"kind": "omit"
}
}
},
"bindings": {},
"catalog": {
"cover": "catalog.cover",
"thumbnail": "catalog.thumbnail",
"icon": "catalog.icon"
}
}
}

@ -0,0 +1,54 @@
{
"id": "drop-13",
"fromRevision": 12,
"toRevision": 13,
"steps": [
{
"id": "fall",
"effect": "disc.drop",
"trigger": {
"kind": "after",
"steps": []
},
"params": {
"column": 3,
"row": 5
},
"emits": ["impact"],
"blocking": true,
"timeoutMs": 1500
},
{
"id": "impact",
"effect": "sound.play",
"trigger": {
"kind": "marker",
"step": "fall",
"marker": "impact"
},
"params": {
"resource": "sound.drop",
"channel": "board",
"policy": "mix",
"maxLateMs": 150
},
"emits": [],
"blocking": false,
"timeoutMs": 800
},
{
"id": "finish",
"effect": "line.highlight",
"trigger": {
"kind": "after",
"steps": ["fall"]
},
"params": {
"cells": [35, 36, 37, 38]
},
"emits": [],
"blocking": true,
"timeoutMs": 1000
}
]
}

@ -0,0 +1,194 @@
{
"format": "juegoland.package",
"formatVersion": 1,
"id": "hundido",
"version": "1.0.0",
"engineApiVersion": 1,
"metadata": {
"name": "Hundido",
"description": "Paquete de referencia de Hundido; especificación previa a su implementación.",
"authors": ["Juegoland"],
"license": "UNLICENSED",
"defaultLocale": "es",
"locales": {
"es": "locales/es.json"
}
},
"dependencies": [],
"kind": "game",
"classification": {
"category": "board",
"tags": ["hundido"],
"competition": "individual"
},
"players": {
"min": 2,
"max": 2
},
"capabilities": {
"hiddenInformation": true,
"simultaneousActions": true,
"deadlines": false
},
"components": {
"board": {
"kind": "board",
"definition": {
"source": "local",
"path": "components/board.json"
},
"copies": 2
},
"fleet": {
"kind": "pieces",
"definition": {
"source": "local",
"path": "components/fleet.json"
},
"copies": 2
}
},
"rules": {
"entry": "rules/index.ts",
"version": "1.0.0",
"stateSchemaVersion": 1,
"viewSchemaVersion": 1,
"eventSchemaVersion": 1,
"schemas": {
"config": "schemas/config.json",
"data": "schemas/data.json",
"view": "schemas/view.json",
"context": "schemas/context.json",
"event": "schemas/event.json",
"visibleEvent": "schemas/visible-event.json"
}
},
"presentation": {
"entry": "presentation/index.ts",
"apiVersion": 1,
"bundleVersion": "1.0.0",
"resources": {
"catalog.cover": {
"kind": "graphic",
"path": "presentation/catalog.ts",
"load": "catalog",
"required": true,
"fallback": {
"kind": "text",
"key": "catalog.name"
}
},
"catalog.thumbnail": {
"kind": "graphic",
"path": "presentation/catalog.ts",
"load": "catalog",
"required": true,
"fallback": {
"kind": "text",
"key": "catalog.name"
}
},
"catalog.icon": {
"kind": "graphic",
"path": "presentation/catalog.ts",
"load": "catalog",
"required": true,
"fallback": {
"kind": "text",
"key": "catalog.name"
}
},
"fleet.ships": {
"kind": "atlas",
"path": "assets/fleet.png",
"load": "board",
"required": true,
"width": 1024,
"height": 1536,
"regions": {
"portaaviones": {
"x": 14,
"y": 48,
"width": 999,
"height": 322
},
"acorazado": {
"x": 13,
"y": 395,
"width": 1000,
"height": 270
},
"crucero": {
"x": 72,
"y": 694,
"width": 870,
"height": 229
},
"submarino": {
"x": 110,
"y": 956,
"width": 804,
"height": 184
},
"destructor": {
"x": 219,
"y": 1155,
"width": 590,
"height": 195
},
"lancha": {
"x": 371,
"y": 1350,
"width": 287,
"height": 164
}
},
"fallback": {
"kind": "text",
"key": "fleet.label"
}
},
"sound.hit": {
"kind": "audio",
"path": "assets/hit.wav",
"load": "phase",
"phase": "battle",
"required": false,
"fallback": {
"kind": "omit"
}
},
"sound.miss": {
"kind": "audio",
"path": "assets/miss.wav",
"load": "phase",
"phase": "battle",
"required": false,
"fallback": {
"kind": "omit"
}
},
"sound.sunk": {
"kind": "audio",
"path": "assets/sunk.wav",
"load": "phase",
"phase": "battle",
"required": false,
"fallback": {
"kind": "omit"
}
}
},
"bindings": {
"fleet": {
"source": "local",
"path": "presentation/fleet-artwork.json"
}
},
"catalog": {
"cover": "catalog.cover",
"thumbnail": "catalog.thumbnail",
"icon": "catalog.icon"
}
}
}

@ -0,0 +1,47 @@
[
{
"format": "juegoland.package",
"formatVersion": 1,
"id": "playing-cards",
"version": "1.0.0",
"engineApiVersion": 1,
"metadata": {
"name": "playing-cards",
"description": "Biblioteca de referencia del contrato Juegoland.",
"authors": ["Juegoland"],
"license": "UNLICENSED",
"defaultLocale": "es",
"locales": {
"es": "locales/es.json"
}
},
"dependencies": [],
"kind": "components",
"exports": {
"poker52": "definitions/poker-52.json",
"espanola40": "definitions/espanola-40.json"
}
},
{
"format": "juegoland.package",
"formatVersion": 1,
"id": "cards-poker",
"version": "1.0.0",
"engineApiVersion": 1,
"metadata": {
"name": "cards-poker",
"description": "Biblioteca de referencia del contrato Juegoland.",
"authors": ["Juegoland"],
"license": "UNLICENSED",
"defaultLocale": "es",
"locales": {
"es": "locales/es.json"
}
},
"dependencies": [],
"kind": "assets",
"exports": {
"standard": "artwork/standard.json"
}
}
]

@ -0,0 +1,187 @@
{
"schemaVersion": 1,
"resources": {
"cards.faces": {
"kind": "graphic",
"path": "graphics/poker-faces.ts",
"load": "board",
"required": true,
"fallback": {
"kind": "text",
"key": "cards.face"
}
},
"cards.back": {
"kind": "graphic",
"path": "graphics/poker-back.ts",
"load": "board",
"required": true,
"fallback": {
"kind": "text",
"key": "cards.hidden"
}
}
},
"slots": {
"picas-1": {
"resourceId": "cards.faces"
},
"picas-2": {
"resourceId": "cards.faces"
},
"picas-3": {
"resourceId": "cards.faces"
},
"picas-4": {
"resourceId": "cards.faces"
},
"picas-5": {
"resourceId": "cards.faces"
},
"picas-6": {
"resourceId": "cards.faces"
},
"picas-7": {
"resourceId": "cards.faces"
},
"picas-8": {
"resourceId": "cards.faces"
},
"picas-9": {
"resourceId": "cards.faces"
},
"picas-10": {
"resourceId": "cards.faces"
},
"picas-11": {
"resourceId": "cards.faces"
},
"picas-12": {
"resourceId": "cards.faces"
},
"picas-13": {
"resourceId": "cards.faces"
},
"corazones-1": {
"resourceId": "cards.faces"
},
"corazones-2": {
"resourceId": "cards.faces"
},
"corazones-3": {
"resourceId": "cards.faces"
},
"corazones-4": {
"resourceId": "cards.faces"
},
"corazones-5": {
"resourceId": "cards.faces"
},
"corazones-6": {
"resourceId": "cards.faces"
},
"corazones-7": {
"resourceId": "cards.faces"
},
"corazones-8": {
"resourceId": "cards.faces"
},
"corazones-9": {
"resourceId": "cards.faces"
},
"corazones-10": {
"resourceId": "cards.faces"
},
"corazones-11": {
"resourceId": "cards.faces"
},
"corazones-12": {
"resourceId": "cards.faces"
},
"corazones-13": {
"resourceId": "cards.faces"
},
"diamantes-1": {
"resourceId": "cards.faces"
},
"diamantes-2": {
"resourceId": "cards.faces"
},
"diamantes-3": {
"resourceId": "cards.faces"
},
"diamantes-4": {
"resourceId": "cards.faces"
},
"diamantes-5": {
"resourceId": "cards.faces"
},
"diamantes-6": {
"resourceId": "cards.faces"
},
"diamantes-7": {
"resourceId": "cards.faces"
},
"diamantes-8": {
"resourceId": "cards.faces"
},
"diamantes-9": {
"resourceId": "cards.faces"
},
"diamantes-10": {
"resourceId": "cards.faces"
},
"diamantes-11": {
"resourceId": "cards.faces"
},
"diamantes-12": {
"resourceId": "cards.faces"
},
"diamantes-13": {
"resourceId": "cards.faces"
},
"treboles-1": {
"resourceId": "cards.faces"
},
"treboles-2": {
"resourceId": "cards.faces"
},
"treboles-3": {
"resourceId": "cards.faces"
},
"treboles-4": {
"resourceId": "cards.faces"
},
"treboles-5": {
"resourceId": "cards.faces"
},
"treboles-6": {
"resourceId": "cards.faces"
},
"treboles-7": {
"resourceId": "cards.faces"
},
"treboles-8": {
"resourceId": "cards.faces"
},
"treboles-9": {
"resourceId": "cards.faces"
},
"treboles-10": {
"resourceId": "cards.faces"
},
"treboles-11": {
"resourceId": "cards.faces"
},
"treboles-12": {
"resourceId": "cards.faces"
},
"treboles-13": {
"resourceId": "cards.faces"
}
},
"back": {
"resourceId": "cards.back"
},
"aspectRatio": 0.7142857142857143
}

@ -0,0 +1,119 @@
{
"format": "juegoland.package",
"formatVersion": 1,
"id": "solitario",
"version": "1.0.0",
"engineApiVersion": 1,
"metadata": {
"name": "Solitario Klondike",
"description": "Paquete de referencia de Solitario Klondike; especificación previa a su implementación.",
"authors": ["Juegoland"],
"license": "UNLICENSED",
"defaultLocale": "es",
"locales": {
"es": "locales/es.json"
}
},
"dependencies": [
{
"id": "playing-cards",
"version": "1.0.0",
"kind": "components"
},
{
"id": "cards-poker",
"version": "1.0.0",
"kind": "assets"
}
],
"kind": "game",
"classification": {
"category": "solitaire",
"tags": ["solitario"],
"competition": "solo"
},
"players": {
"min": 1,
"max": 1
},
"capabilities": {
"hiddenInformation": true,
"simultaneousActions": false,
"deadlines": false
},
"components": {
"mainDeck": {
"kind": "deck",
"definition": {
"source": "dependency",
"packageId": "playing-cards",
"export": "poker52"
},
"copies": 1
}
},
"rules": {
"entry": "rules/index.ts",
"version": "1.0.0",
"stateSchemaVersion": 1,
"viewSchemaVersion": 1,
"eventSchemaVersion": 1,
"schemas": {
"config": "schemas/config.json",
"data": "schemas/data.json",
"view": "schemas/view.json",
"context": "schemas/context.json",
"event": "schemas/event.json",
"visibleEvent": "schemas/visible-event.json"
}
},
"presentation": {
"entry": "presentation/index.ts",
"apiVersion": 1,
"bundleVersion": "1.0.0",
"resources": {
"catalog.cover": {
"kind": "graphic",
"path": "presentation/catalog.ts",
"load": "catalog",
"required": true,
"fallback": {
"kind": "text",
"key": "catalog.name"
}
},
"catalog.thumbnail": {
"kind": "graphic",
"path": "presentation/catalog.ts",
"load": "catalog",
"required": true,
"fallback": {
"kind": "text",
"key": "catalog.name"
}
},
"catalog.icon": {
"kind": "graphic",
"path": "presentation/catalog.ts",
"load": "catalog",
"required": true,
"fallback": {
"kind": "text",
"key": "catalog.name"
}
}
},
"bindings": {
"mainDeck": {
"source": "dependency",
"packageId": "cards-poker",
"export": "standard"
}
},
"catalog": {
"cover": "catalog.cover",
"thumbnail": "catalog.thumbnail",
"icon": "catalog.icon"
}
}
}

@ -0,0 +1,202 @@
[
{
"protocolVersion": 1,
"type": "game.sync",
"matchId": "cc749a64-c8e4-4d74-a913-54b39f4c9089",
"requestId": "439221f0-6f92-4c94-8fcf-a420f0dddf31",
"knownRevision": null,
"client": {
"engineApiVersion": 1,
"presentationApiVersion": 1,
"presentations": [
{
"id": "solitario",
"rulesVersion": "1.0.0",
"viewSchemaVersion": 1,
"eventSchemaVersion": 1
}
]
}
},
{
"protocolVersion": 1,
"type": "game.command",
"matchId": "cc749a64-c8e4-4d74-a913-54b39f4c9089",
"commandId": "30c474dd-030a-4e0e-883f-c8a6d636c8c9",
"expected": {
"kind": "match",
"revision": 12
},
"action": {
"type": "drop",
"payload": {
"column": 3
}
}
},
{
"protocolVersion": 1,
"type": "game.command",
"matchId": "cc749a64-c8e4-4d74-a913-54b39f4c9089",
"commandId": "19a772c9-8569-43b8-a4be-294402f58043",
"expected": {
"kind": "actor",
"phaseEpoch": 0,
"actorRevision": 2
},
"action": {
"type": "place",
"payload": {
"shipId": "fleet:p0:lancha",
"row": 2,
"column": 3,
"vertical": false
}
}
},
{
"protocolVersion": 1,
"type": "game.ack",
"matchId": "cc749a64-c8e4-4d74-a913-54b39f4c9089",
"commandId": "30c474dd-030a-4e0e-883f-c8a6d636c8c9",
"duplicate": false,
"receipt": {
"status": "applied",
"appliedRevision": 13
}
},
{
"protocolVersion": 1,
"type": "game.ack",
"matchId": "cc749a64-c8e4-4d74-a913-54b39f4c9089",
"commandId": "30c474dd-030a-4e0e-883f-c8a6d636c8c9",
"duplicate": true,
"receipt": {
"status": "applied",
"appliedRevision": 13
}
},
{
"protocolVersion": 1,
"type": "game.ack",
"matchId": "cc749a64-c8e4-4d74-a913-54b39f4c9089",
"commandId": "e1d20e1b-5aa4-4468-8e78-23440d5f76c0",
"duplicate": false,
"receipt": {
"status": "rejected",
"code": "STALE_REVISION",
"observedRevision": 13
}
},
{
"protocolVersion": 1,
"type": "game.snapshot",
"matchId": "cc749a64-c8e4-4d74-a913-54b39f4c9089",
"subscriptionId": "d0dd4fae-b908-4ab2-a12c-32c87d1cf1a2",
"delivery": {
"kind": "sync",
"requestId": "439221f0-6f92-4c94-8fcf-a420f0dddf31"
},
"revision": 0,
"game": {
"id": "solitario",
"rulesVersion": "1.0.0",
"viewSchemaVersion": 1,
"eventSchemaVersion": 1
},
"status": "active",
"view": {
"example": "wire-envelope-only"
},
"context": {
"phase": "playing",
"actors": ["p0"]
},
"availableActions": [],
"outcome": null,
"events": []
},
{
"protocolVersion": 1,
"type": "game.snapshot",
"matchId": "cc749a64-c8e4-4d74-a913-54b39f4c9089",
"subscriptionId": "d0dd4fae-b908-4ab2-a12c-32c87d1cf1a2",
"delivery": {
"kind": "live",
"fromRevision": 0
},
"revision": 1,
"game": {
"id": "solitario",
"rulesVersion": "1.0.0",
"viewSchemaVersion": 1,
"eventSchemaVersion": 1
},
"status": "completed",
"view": {
"example": "wire-envelope-only"
},
"context": {
"phase": "playing",
"actors": ["p0"]
},
"availableActions": [],
"outcome": {
"kind": "completed",
"reasonCode": "resigned",
"participants": {
"p0": {
"outcome": "loss",
"metrics": {}
}
}
},
"events": [
{
"eventId": "598c0e3a-72e0-48a8-a0b0-a11a8b70584e",
"revision": 1,
"order": 0,
"type": "match.completed",
"payload": {
"reasonCode": "resigned"
}
}
]
},
{
"protocolVersion": 1,
"type": "game.command-status",
"matchId": "cc749a64-c8e4-4d74-a913-54b39f4c9089",
"requestId": "439221f0-6f92-4c94-8fcf-a420f0dddf31",
"commandId": "30c474dd-030a-4e0e-883f-c8a6d636c8c9"
},
{
"protocolVersion": 1,
"type": "game.command-unknown",
"matchId": "cc749a64-c8e4-4d74-a913-54b39f4c9089",
"requestId": "439221f0-6f92-4c94-8fcf-a420f0dddf31",
"commandId": "30c474dd-030a-4e0e-883f-c8a6d636c8c9"
},
{
"protocolVersion": 1,
"type": "game.cursor",
"matchId": "cc749a64-c8e4-4d74-a913-54b39f4c9089",
"subscriptionId": "d0dd4fae-b908-4ab2-a12c-32c87d1cf1a2",
"revision": 13
},
{
"protocolVersion": 1,
"type": "game.unsubscribe",
"matchId": "cc749a64-c8e4-4d74-a913-54b39f4c9089",
"subscriptionId": "d0dd4fae-b908-4ab2-a12c-32c87d1cf1a2"
},
{
"protocolVersion": 1,
"type": "game.error",
"matchId": "cc749a64-c8e4-4d74-a913-54b39f4c9089",
"requestId": "439221f0-6f92-4c94-8fcf-a420f0dddf31",
"code": "INCOMPATIBLE_CLIENT",
"recovery": "reload-client",
"messageKey": "game.reloadRequired"
}
]

@ -0,0 +1,175 @@
# Formato de paquetes y protocolo de partidas — Juegoland v1
El catálogo se limita a juegos propios publicados por el equipo. La [propuesta de arquitectura Go](../platform/architecture.md) distingue este formato/protocolo de datos del SDK ejecutable TypeScript: el perfil nativo Go y un posible transporte WebSocket requieren contratos versionados propios. Este documento y los esquemas v1 no adquieren soporte Go por esa recomendación.
**Estado:** contrato propuesto para el motor propio, 5 de octubre de 2026. Este documento explica cómo se distribuye un juego y cómo se comunica una partida. El [modelo completo](../game-engine-proposal.md) define reglas, presentación, persistencia y migración. El servidor actual aún requiere el refactor para hablar este protocolo.
Las fuentes verificables del contrato son [schema.ts](schema.ts), los [tipos del SDK](contracts.ts), los [esquemas JSON](package.schema.json) y los [mensajes de ejemplo](examples/wire.json). Los esquemas JSON describen estructura; las comprobaciones entre campos y paquetes están en los validadores de `schema.ts`.
## 1. Un paquete de juego
El manifiesto de distribución es `game.json`. Una biblioteca de componentes o recursos usa `package.json`. Ambos tienen:
| Campo | Tipo y significado |
| ------------------ | --------------------------------------------------------------------------------- |
| `format` | Literal `juegoland.package`. |
| `formatVersion` | Entero `1`. Cambia cuando cambia la estructura del manifiesto. |
| `id` | Identificador estable, 1–80 caracteres, comienza por letra minúscula. |
| `version` | Versión exacta `mayor.menor.parche`, sin rangos ni sufijos. |
| `engineApiVersion` | `1`; versión del contrato de funciones del motor. |
| `metadata` | Nombre, descripción, autores, licencia, idioma principal y rutas de traducciones. |
| `dependencies` | Lista de `{ id, version, kind }`, con tipo `components` o `assets`. |
| `kind` | `game`, `components` o `assets`. |
El manifiesto de juego añade:
| Campo | Estructura |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `classification` | `category`, `tags` y `competition`: `individual`, `teams`, `cooperative`, `solo` u `open`. |
| `players` | `min` y `max`, enteros de 1 a 64, con `min ≤ max`. |
| `capabilities` | Booleanos `hiddenInformation`, `simultaneousActions`, `deadlines`. |
| `components` | Mapa por identificador. Cada entrada declara `kind`, `definition` y `copies` (1–100). |
| `rules` | `entry`, `version`, versiones de los esquemas de estado, vista y eventos, y rutas de los esquemas de configuración, datos, vista, contexto, evento interno y evento visible. |
| `presentation` | `entry`, `apiVersion: 1`, `bundleVersion`, `resources`, `bindings` y `catalog`. |
Una biblioteca contiene `exports: { nombre: ruta }`. El código de reglas puede cargar bibliotecas `components`; la presentación puede cargar `assets`. Una biblioteca solo puede depender de otras de su mismo tipo.
### Referencias y recursos
Una definición local usa `{ "source": "local", "path": "components/board.json" }`. Una compartida usa una referencia por paquete y exportación:
```json
{
"source": "dependency",
"packageId": "playing-cards",
"export": "poker52"
}
```
La dependencia fija `playing-cards@1.0.0` en `dependencies`. El cargador rechaza exportaciones ausentes, dependencias circulares y versiones distintas del mismo paquete dentro del árbol de un juego.
Los componentes permitidos son `deck`, `dice`, `wheel`, `board`, `pieces` y `custom`. Su estructura está en [component.schema.json](component.schema.json). Una definición describe lo que existe; cada partida crea instancias con identidad propia. En Solitario, `poker52` define 52 caras y `mainDeck` crea una copia. Sus ilustraciones vienen por separado de `cards-poker/standard`. El [manifiesto de Solitario](examples/solitario.game.json) contiene todos esos campos como ejemplo estructural.
`presentation.resources` es un mapa de entradas con:
| Campo | Significado |
| ---------------------------- | ---------------------------------------------------------------------- |
| `kind` | `image`, `atlas`, `audio`, `font` o `graphic`. |
| `path` | Ruta relativa dentro de la distribución. |
| `load` | `catalog`, `board`, `phase` o `lazy`; `phase` exige el nombre de fase. |
| `required` | Si el recurso es necesario para representar el juego. |
| `fallback` | `text` con clave traducible, otro `resource` u `omit` si es opcional. |
| `width`, `height`, `regions` | Geometría de imágenes y atlas. |
| `altKey` | Clave de texto alternativo, cuando corresponda. |
`catalog` identifica obligatoriamente `cover`, `thumbnail` e `icon` entre esos recursos. `bindings` asocia componentes con arte local o de biblioteca. Un [conjunto visual](examples/poker.resource-set.json) contiene slots por carta, recursos y reverso; debe cubrir todas las caras que el renderer vaya a mostrar. Un recurso `graphic` implementa `GraphicDefinition` y devuelve un SVG a partir del slot y su definición.
Todos los `entry`, esquemas, recursos y exportaciones son rutas relativas normalizadas. El publicador debe comprobar que cada archivo existe, permanece dentro del paquete, tiene el tipo esperado y coincide con su hash. Los manifiestos de ejemplo describen esas rutas, pero **todavía no contienen los módulos ni los archivos necesarios para instalar los juegos**.
### Lock y versiones
El [lock.schema.json](lock.schema.json) fija raíz, dependencias resueltas y archivos con SHA-256, tamaño, tipo de contenido y URL pública opcional. El código privado de reglas tiene `url: null`. Los hashes se calculan de los bytes reales y de inventarios JSON canónicos; [la especificación general](../game-engine-proposal.md#publicación-y-lock) define el orden y la canonicalización.
Una partida fija `packageDigest`, `dependencyLockDigest`, versión de reglas y versión del esquema persistido. No se interpreta un estado antiguo con una versión nueva por coincidencia de identificador. `bundleVersion` identifica cambios de presentación y `protocolVersion` identifica el contrato de red.
## 2. Mensajes del protocolo
Todos los mensajes llevan `protocolVersion: 1` y `type`. Salvo `game.error` antes de identificar una partida válida, llevan `matchId` UUID. `game.sync` y `game.command-status` llevan `requestId` UUID; los comandos usan `commandId` UUID para idempotencia.
El transporte previsto es Socket.IO sobre el namespace principal. Todos los sobres v1 se envían como un objeto JSON mediante un único evento `game:message`, en ambos sentidos; los recibos usan ese evento, no el callback de acuse de Socket.IO. El campo `type` distingue el mensaje. El adaptador anterior podrá convivir hasta migrar los clientes. El cliente se autentica en la plataforma y el servidor deriva su asiento. **Ningún comando de cliente contiene `actor` ni un estado nuevo.** El límite inicial del sobre es 64 KiB de JSON y profundidad 32, además de los límites específicos de cada juego.
| `type` | Dirección | Campos específicos |
| ---------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `game.sync` | Cliente → servidor | `requestId`, `knownRevision` numérica o nula, `client` con versiones de API y tuplas de presentación admitidas. |
| `game.snapshot` | Servidor → cliente | `subscriptionId`, `delivery`, `revision`, `game`, `status`, `view`, `context`, `availableActions`, `outcome`, `events`. |
| `game.command` | Cliente → servidor | `commandId`, `expected`, `action: { type, payload }`. |
| `game.ack` | Servidor → cliente | `commandId`, `duplicate`, `receipt`. |
| `game.command-status` | Cliente → servidor | `requestId`, `commandId`. |
| `game.command-unknown` | Servidor → cliente | `requestId`, `commandId`. |
| `game.cursor` | Servidor → cliente | `subscriptionId`, última `revision` confirmada. |
| `game.unsubscribe` | Cliente → servidor | `subscriptionId` propio. |
| `game.error` | Servidor → cliente | `code`, `recovery`, `messageKey` y los identificadores disponibles. |
### Sincronización y vista
`game.sync.client` declara `engineApiVersion: 1`, `presentationApiVersion: 1` y al menos un contrato exacto:
```json
{
"id": "solitario",
"rulesVersion": "1.0.0",
"viewSchemaVersion": 1,
"eventSchemaVersion": 1
}
```
El servidor comprueba la tupla completa antes de entregar una vista. Un snapshot de sincronización declara `delivery: { "kind": "sync", "requestId": "…" }` y `events: []`. Su `subscriptionId` identifica el flujo nuevo. El servidor instala primero la suscripción, almacena cambios concurrentes, lee la revisión R confirmada, envía el snapshot R y después entrega los cambios posteriores a R en orden.
Un snapshot vivo declara `delivery: { "kind": "live", "fromRevision": R }` y lleva `revision: R + 1`. Contiene una vista completa autorizada para ese destinatario. `view` y `context` deben cumplir además los esquemas específicos del juego. `game` contiene la tupla de contrato usada. `status` es `active`, `completed` o `cancelled`; `outcome` es `null` exactamente mientras está activo. Las partidas terminadas no ofrecen acciones.
Cada evento visible lleva `eventId` UUID estable, revisión, `order` consecutivo desde 0, tipo y payload. Una regla puede ocultar un evento interno o proyectarlo en varios visibles. La sincronización no reproduce eventos anteriores. Si el cliente pierde una revisión, recibe una revisión duplicada, vuelve al primer plano o ve un cursor por delante de su vista, se resincroniza. Solo acepta la respuesta de su solicitud más reciente y los mensajes de su suscripción actual.
### Acción, precondiciones y recibo
Una acción usa `{ "type": "drop", "payload": { "column": 3 } }`. El módulo del juego valida ese payload. Hay dos precondiciones:
Ejemplo de comando completo para Conecta 4:
```json
{
"protocolVersion": 1,
"type": "game.command",
"matchId": "cc749a64-c8e4-4d74-a913-54b39f4c9089",
"commandId": "30c474dd-030a-4e0e-883f-c8a6d636c8c9",
"expected": { "kind": "match", "revision": 12 },
"action": { "type": "drop", "payload": { "column": 3 } }
}
```
```json
{ "kind": "match", "revision": 12 }
```
```json
{ "kind": "actor", "phaseEpoch": 0, "actorRevision": 2 }
```
`match` exige la revisión global exacta. `actor` se permite solo para operaciones independientes declaradas por el módulo, como colocar un barco privado. Ambas se revalidan bajo el bloqueo de la partida; al cambiar de fase aumenta `phaseEpoch` y una colocación tardía se rechaza.
El recibo aplicado es `{ "status": "applied", "appliedRevision": 13 }`. El rechazado definitivo es `{ "status": "rejected", "code": "STALE_REVISION", "observedRevision": 13 }`; los códigos permitidos son `STALE_REVISION`, `STALE_PHASE`, `STALE_ACTOR`, `ILLEGAL_ACTION` y `MATCH_FINISHED`. Errores transitorios usan `game.error`, no un rechazo definitivo.
La identidad idempotente es partida + principal autenticado + `commandId`. El servidor guarda el recibo con el estado confirmado. Repetir **el mismo sobre completo**, incluida la precondición, devuelve el mismo recibo con `duplicate: true`; reutilizar el UUID con otro sobre da `COMMAND_ID_REUSED`. El cliente conserva los comandos pendientes y reenvía el mismo UUID después de un corte. `game.command-unknown` significa que no hay recibo almacenado al consultar: un envío retrasado todavía podría llegar.
La vista puede llegar antes o después del recibo. El recibo informa sobre la operación; la vista del snapshot determina qué se muestra. Un cambio confirmado incrementa la revisión una vez; una consulta o rechazo no la incrementa.
### Resultados, privacidad y errores
`outcome` completado contiene `reasonCode` y un mapa por asiento. Cada participante tiene `outcome: win | loss | draw | neutral`, `metrics` y puntuación, puesto o equipo opcionales. Esto admite derrota en Solitario y derrota de todos en un juego cooperativo. La cancelación usa `{ "kind": "cancelled", "reasonCode": "…" }`. El módulo decide cuándo terminan sus reglas y qué resulta de una retirada.
El servidor proyecta por destinatario `view`, `context`, ofertas, resultado y eventos. Ninguno copia automáticamente el estado privado. El esquema de red acepta objetos JSON en esos campos; los esquemas del juego y las pruebas de privacidad completan la validación.
`game.error` usa códigos del [esquema de protocolo](protocol.schema.json) y una recuperación explícita: `none`, `sync`, `retry-same-command`, `reload-client` o `reauthenticate`. No incluye datos privados ni trazas internas.
## 3. Orden de procesamiento y entrega
1. Validar sobre y límites, autenticar, autorizar asiento y buscar un recibo previo.
2. Bloquear la partida, verificar la versión fijada y las precondiciones.
3. Ejecutar reglas síncronas con servicios deterministas; validar estado y proyecciones.
4. Confirmar juntos estado, revisión, recibo, eventos, azar, plazos y publicaciones pendientes.
5. Enviar el recibo y publicar snapshots completos en orden de revisión.
6. Ante fallo de envío, reintentar la publicación pendiente. El cliente descarta duplicados y recupera huecos con `game.sync`.
El cursor periódico detecta incluso la última publicación perdida. La frecuencia inicial prevista es 15 segundos mientras existe suscripción. El servidor de producción de varias instancias necesitará coordinación de bloqueo, publicaciones y suscripciones.
## 4. Cómo comprobar el formato
Desde la raíz del repositorio:
```powershell
node docs/game-engine/verify.ts
.\node_modules\.bin\tsc.cmd --project docs/game-engine/tsconfig.json
```
El primer comando valida ejemplos, dependencias, invariantes comunes y que los seis JSON Schema coincidan con `schema.ts`. El segundo comprueba las firmas TypeScript. Véanse los [ejemplos de paquetes](examples/README.md) y los [sobres de red](examples/wire.json).
Estas comprobaciones verifican el **diseño del formato**. El compilador de paquetes, los archivos reales de cada ejemplo y las pruebas del servidor, sockets y navegador pertenecen a la implementación del motor.

@ -0,0 +1,109 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"formatVersion": {
"type": "number",
"const": 1
},
"root": {
"type": "object",
"properties": {
"id": {
"$ref": "#/$defs/__schema0"
},
"version": {
"$ref": "#/$defs/__schema1"
},
"sha256": {
"$ref": "#/$defs/__schema2"
}
},
"required": ["id", "version", "sha256"],
"additionalProperties": false
},
"resolved": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"$ref": "#/$defs/__schema0"
},
"version": {
"$ref": "#/$defs/__schema1"
},
"sha256": {
"$ref": "#/$defs/__schema2"
}
},
"required": ["id", "version", "sha256"],
"additionalProperties": false
}
},
"files": {
"type": "array",
"items": {
"type": "object",
"properties": {
"packageId": {
"$ref": "#/$defs/__schema0"
},
"path": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9_-])?(?:\\/[a-zA-Z0-9_-](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9_-])?)*$"
},
"sha256": {
"$ref": "#/$defs/__schema2"
},
"bytes": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"mediaType": {
"type": "string",
"minLength": 1
},
"url": {
"anyOf": [
{
"type": "string",
"format": "uri"
},
{
"type": "null"
}
]
}
},
"required": [
"packageId",
"path",
"sha256",
"bytes",
"mediaType",
"url"
],
"additionalProperties": false
}
}
},
"required": ["formatVersion", "root", "resolved", "files"],
"additionalProperties": false,
"$defs": {
"__schema0": {
"type": "string",
"pattern": "^[a-z][a-zA-Z0-9.-]{0,79}$"
},
"__schema1": {
"type": "string",
"pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$"
},
"__schema2": {
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
},
"$id": "urn:juegoland:lock:1"
}

@ -0,0 +1,550 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"anyOf": [
{
"type": "object",
"properties": {
"format": {
"$ref": "#/$defs/__schema0"
},
"formatVersion": {
"$ref": "#/$defs/__schema1"
},
"id": {
"$ref": "#/$defs/__schema2"
},
"version": {
"$ref": "#/$defs/__schema3"
},
"engineApiVersion": {
"$ref": "#/$defs/__schema4"
},
"metadata": {
"$ref": "#/$defs/__schema5"
},
"dependencies": {
"$ref": "#/$defs/__schema8"
},
"kind": {
"type": "string",
"const": "game"
},
"classification": {
"type": "object",
"properties": {
"category": {
"$ref": "#/$defs/__schema2"
},
"tags": {
"type": "array",
"items": {
"$ref": "#/$defs/__schema2"
}
},
"competition": {
"type": "string",
"enum": ["individual", "teams", "cooperative", "solo", "open"]
}
},
"required": ["category", "tags", "competition"],
"additionalProperties": false
},
"players": {
"type": "object",
"properties": {
"min": {
"type": "integer",
"minimum": 1,
"maximum": 64
},
"max": {
"type": "integer",
"minimum": 1,
"maximum": 64
}
},
"required": ["min", "max"],
"additionalProperties": false
},
"capabilities": {
"type": "object",
"properties": {
"hiddenInformation": {
"type": "boolean"
},
"simultaneousActions": {
"type": "boolean"
},
"deadlines": {
"type": "boolean"
}
},
"required": ["hiddenInformation", "simultaneousActions", "deadlines"],
"additionalProperties": false
},
"components": {
"type": "object",
"propertyNames": {
"$ref": "#/$defs/__schema2"
},
"additionalProperties": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["deck", "dice", "wheel", "board", "pieces", "custom"]
},
"definition": {
"$ref": "#/$defs/__schema9"
},
"copies": {
"type": "integer",
"minimum": 1,
"maximum": 100
}
},
"required": ["kind", "definition", "copies"],
"additionalProperties": false
}
},
"rules": {
"type": "object",
"properties": {
"entry": {
"$ref": "#/$defs/__schema7"
},
"version": {
"$ref": "#/$defs/__schema3"
},
"stateSchemaVersion": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"viewSchemaVersion": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"eventSchemaVersion": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"schemas": {
"type": "object",
"properties": {
"config": {
"$ref": "#/$defs/__schema7"
},
"data": {
"$ref": "#/$defs/__schema7"
},
"view": {
"$ref": "#/$defs/__schema7"
},
"context": {
"$ref": "#/$defs/__schema7"
},
"event": {
"$ref": "#/$defs/__schema7"
},
"visibleEvent": {
"$ref": "#/$defs/__schema7"
}
},
"required": [
"config",
"data",
"view",
"context",
"event",
"visibleEvent"
],
"additionalProperties": false
}
},
"required": [
"entry",
"version",
"stateSchemaVersion",
"viewSchemaVersion",
"eventSchemaVersion",
"schemas"
],
"additionalProperties": false
},
"presentation": {
"type": "object",
"properties": {
"entry": {
"$ref": "#/$defs/__schema7"
},
"apiVersion": {
"type": "number",
"const": 1
},
"bundleVersion": {
"$ref": "#/$defs/__schema3"
},
"resources": {
"type": "object",
"propertyNames": {
"$ref": "#/$defs/__schema2"
},
"additionalProperties": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["image", "atlas", "audio", "font", "graphic"]
},
"path": {
"$ref": "#/$defs/__schema7"
},
"load": {
"type": "string",
"enum": ["catalog", "board", "phase", "lazy"]
},
"phase": {
"$ref": "#/$defs/__schema2"
},
"required": {
"type": "boolean"
},
"altKey": {
"$ref": "#/$defs/__schema2"
},
"width": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"height": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"regions": {
"type": "object",
"propertyNames": {
"$ref": "#/$defs/__schema2"
},
"additionalProperties": {
"type": "object",
"properties": {
"x": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"y": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"width": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"height": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"required": ["x", "y", "width", "height"],
"additionalProperties": false
}
},
"fallback": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "text"
},
"key": {
"$ref": "#/$defs/__schema2"
}
},
"required": ["kind", "key"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "resource"
},
"resourceId": {
"$ref": "#/$defs/__schema2"
}
},
"required": ["kind", "resourceId"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "omit"
}
},
"required": ["kind"],
"additionalProperties": false
}
]
}
},
"required": ["kind", "path", "load", "required", "fallback"],
"additionalProperties": false
}
},
"bindings": {
"type": "object",
"propertyNames": {
"$ref": "#/$defs/__schema2"
},
"additionalProperties": {
"$ref": "#/$defs/__schema9"
}
},
"catalog": {
"type": "object",
"properties": {
"cover": {
"$ref": "#/$defs/__schema2"
},
"thumbnail": {
"$ref": "#/$defs/__schema2"
},
"icon": {
"$ref": "#/$defs/__schema2"
}
},
"required": ["cover", "thumbnail", "icon"],
"additionalProperties": false
}
},
"required": [
"entry",
"apiVersion",
"bundleVersion",
"resources",
"bindings",
"catalog"
],
"additionalProperties": false
}
},
"required": [
"format",
"formatVersion",
"id",
"version",
"engineApiVersion",
"metadata",
"dependencies",
"kind",
"classification",
"players",
"capabilities",
"components",
"rules",
"presentation"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"format": {
"$ref": "#/$defs/__schema0"
},
"formatVersion": {
"$ref": "#/$defs/__schema1"
},
"id": {
"$ref": "#/$defs/__schema2"
},
"version": {
"$ref": "#/$defs/__schema3"
},
"engineApiVersion": {
"$ref": "#/$defs/__schema4"
},
"metadata": {
"$ref": "#/$defs/__schema5"
},
"dependencies": {
"$ref": "#/$defs/__schema8"
},
"kind": {
"type": "string",
"enum": ["components", "assets"]
},
"exports": {
"type": "object",
"propertyNames": {
"$ref": "#/$defs/__schema2"
},
"additionalProperties": {
"$ref": "#/$defs/__schema7"
}
}
},
"required": [
"format",
"formatVersion",
"id",
"version",
"engineApiVersion",
"metadata",
"dependencies",
"kind",
"exports"
],
"additionalProperties": false
}
],
"$defs": {
"__schema0": {
"type": "string",
"const": "juegoland.package"
},
"__schema1": {
"type": "number",
"const": 1
},
"__schema2": {
"type": "string",
"pattern": "^[a-z][a-zA-Z0-9.-]{0,79}$"
},
"__schema3": {
"type": "string",
"pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$"
},
"__schema4": {
"type": "number",
"const": 1
},
"__schema5": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 2000
},
"authors": {
"minItems": 1,
"type": "array",
"items": {
"$ref": "#/$defs/__schema6"
}
},
"license": {
"type": "string",
"minLength": 1
},
"defaultLocale": {
"type": "string",
"pattern": "^[a-z]{2}(?:-[A-Z]{2})?$"
},
"locales": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"$ref": "#/$defs/__schema7"
}
}
},
"required": [
"name",
"description",
"authors",
"license",
"defaultLocale",
"locales"
],
"additionalProperties": false
},
"__schema6": {
"type": "string",
"minLength": 1
},
"__schema7": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9_-])?(?:\\/[a-zA-Z0-9_-](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9_-])?)*$"
},
"__schema8": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"$ref": "#/$defs/__schema2"
},
"version": {
"$ref": "#/$defs/__schema3"
},
"kind": {
"type": "string",
"enum": ["components", "assets"]
}
},
"required": ["id", "version", "kind"],
"additionalProperties": false
}
},
"__schema9": {
"oneOf": [
{
"type": "object",
"properties": {
"source": {
"type": "string",
"const": "local"
},
"path": {
"$ref": "#/$defs/__schema7"
}
},
"required": ["source", "path"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"source": {
"type": "string",
"const": "dependency"
},
"packageId": {
"$ref": "#/$defs/__schema2"
},
"export": {
"$ref": "#/$defs/__schema2"
}
},
"required": ["source", "packageId", "export"],
"additionalProperties": false
}
]
}
},
"$id": "urn:juegoland:package:1"
}

@ -0,0 +1,150 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"fromRevision": {
"$ref": "#/$defs/__schema0"
},
"toRevision": {
"$ref": "#/$defs/__schema0"
},
"steps": {
"maxItems": 128,
"type": "array",
"items": {
"$ref": "#/$defs/__schema1"
}
}
},
"required": ["id", "fromRevision", "toRevision", "steps"],
"additionalProperties": false,
"$defs": {
"__schema0": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"__schema1": {
"type": "object",
"properties": {
"id": {
"$ref": "#/$defs/__schema2"
},
"effect": {
"$ref": "#/$defs/__schema2"
},
"trigger": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "after"
},
"steps": {
"type": "array",
"items": {
"$ref": "#/$defs/__schema2"
}
}
},
"required": ["kind", "steps"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "marker"
},
"step": {
"$ref": "#/$defs/__schema2"
},
"marker": {
"$ref": "#/$defs/__schema2"
}
},
"required": ["kind", "step", "marker"],
"additionalProperties": false
}
]
},
"params": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"$ref": "#/$defs/__schema3"
}
},
"emits": {
"type": "array",
"items": {
"$ref": "#/$defs/__schema2"
}
},
"blocking": {
"type": "boolean"
},
"timeoutMs": {
"type": "integer",
"minimum": 1,
"maximum": 12000
}
},
"required": [
"id",
"effect",
"trigger",
"params",
"emits",
"blocking",
"timeoutMs"
],
"additionalProperties": false
},
"__schema2": {
"type": "string",
"pattern": "^[a-z][a-zA-Z0-9.-]{0,79}$"
},
"__schema3": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"type": "array",
"items": {
"$ref": "#/$defs/__schema3"
}
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"$ref": "#/$defs/__schema3"
}
}
]
}
},
"$id": "urn:juegoland:presentation-plan:1"
}

@ -0,0 +1,734 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"anyOf": [
{
"type": "object",
"properties": {
"protocolVersion": {
"$ref": "#/$defs/__schema0"
},
"matchId": {
"$ref": "#/$defs/__schema1"
},
"type": {
"type": "string",
"const": "game.command"
},
"commandId": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"expected": {
"$ref": "#/$defs/__schema2"
},
"action": {
"$ref": "#/$defs/__schema4"
}
},
"required": [
"protocolVersion",
"matchId",
"type",
"commandId",
"expected",
"action"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"protocolVersion": {
"$ref": "#/$defs/__schema0"
},
"matchId": {
"$ref": "#/$defs/__schema1"
},
"type": {
"type": "string",
"const": "game.snapshot"
},
"subscriptionId": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"delivery": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "sync"
},
"requestId": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
}
},
"required": ["kind", "requestId"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "live"
},
"fromRevision": {
"$ref": "#/$defs/__schema3"
}
},
"required": ["kind", "fromRevision"],
"additionalProperties": false
}
]
},
"revision": {
"$ref": "#/$defs/__schema3"
},
"game": {
"$ref": "#/$defs/__schema8"
},
"status": {
"type": "string",
"enum": ["active", "completed", "cancelled"]
},
"view": {
"$ref": "#/$defs/__schema6"
},
"context": {
"$ref": "#/$defs/__schema6"
},
"availableActions": {
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "concrete"
},
"action": {
"$ref": "#/$defs/__schema4"
},
"expected": {
"$ref": "#/$defs/__schema2"
}
},
"required": ["kind", "action", "expected"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "parameterized"
},
"actionType": {
"$ref": "#/$defs/__schema5"
},
"inputSchemaId": {
"$ref": "#/$defs/__schema5"
},
"constraints": {
"$ref": "#/$defs/__schema6"
},
"expected": {
"$ref": "#/$defs/__schema2"
}
},
"required": [
"kind",
"actionType",
"inputSchemaId",
"constraints",
"expected"
],
"additionalProperties": false
}
]
}
},
"outcome": {
"anyOf": [
{
"anyOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "completed"
},
"reasonCode": {
"$ref": "#/$defs/__schema5"
},
"participants": {
"type": "object",
"propertyNames": {
"type": "string",
"pattern": "^p(0|[1-9]\\d*)$"
},
"additionalProperties": {
"type": "object",
"properties": {
"outcome": {
"type": "string",
"enum": ["win", "loss", "draw", "neutral"]
},
"score": {
"type": "number"
},
"rank": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"teamId": {
"$ref": "#/$defs/__schema5"
},
"metrics": {
"$ref": "#/$defs/__schema6"
}
},
"required": ["outcome", "metrics"],
"additionalProperties": false
}
}
},
"required": ["kind", "reasonCode", "participants"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "cancelled"
},
"reasonCode": {
"$ref": "#/$defs/__schema5"
}
},
"required": ["kind", "reasonCode"],
"additionalProperties": false
}
]
},
{
"type": "null"
}
]
},
"events": {
"type": "array",
"items": {
"type": "object",
"properties": {
"eventId": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"revision": {
"$ref": "#/$defs/__schema3"
},
"order": {
"$ref": "#/$defs/__schema3"
},
"type": {
"$ref": "#/$defs/__schema5"
},
"payload": {
"$ref": "#/$defs/__schema6"
}
},
"required": ["eventId", "revision", "order", "type", "payload"],
"additionalProperties": false
}
}
},
"required": [
"protocolVersion",
"matchId",
"type",
"subscriptionId",
"delivery",
"revision",
"game",
"status",
"view",
"context",
"availableActions",
"outcome",
"events"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"protocolVersion": {
"$ref": "#/$defs/__schema0"
},
"matchId": {
"$ref": "#/$defs/__schema1"
},
"type": {
"type": "string",
"const": "game.ack"
},
"commandId": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"duplicate": {
"type": "boolean"
},
"receipt": {
"oneOf": [
{
"type": "object",
"properties": {
"status": {
"type": "string",
"const": "applied"
},
"appliedRevision": {
"$ref": "#/$defs/__schema3"
}
},
"required": ["status", "appliedRevision"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"status": {
"type": "string",
"const": "rejected"
},
"code": {
"type": "string",
"enum": [
"STALE_REVISION",
"STALE_PHASE",
"STALE_ACTOR",
"ILLEGAL_ACTION",
"MATCH_FINISHED"
]
},
"observedRevision": {
"$ref": "#/$defs/__schema3"
}
},
"required": ["status", "code", "observedRevision"],
"additionalProperties": false
}
]
}
},
"required": [
"protocolVersion",
"matchId",
"type",
"commandId",
"duplicate",
"receipt"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"protocolVersion": {
"$ref": "#/$defs/__schema0"
},
"matchId": {
"$ref": "#/$defs/__schema1"
},
"requestId": {
"$ref": "#/$defs/__schema9"
},
"type": {
"type": "string",
"const": "game.sync"
},
"knownRevision": {
"anyOf": [
{
"$ref": "#/$defs/__schema3"
},
{
"type": "null"
}
]
},
"client": {
"type": "object",
"properties": {
"engineApiVersion": {
"type": "number",
"const": 1
},
"presentationApiVersion": {
"type": "number",
"const": 1
},
"presentations": {
"minItems": 1,
"type": "array",
"items": {
"$ref": "#/$defs/__schema8"
}
}
},
"required": [
"engineApiVersion",
"presentationApiVersion",
"presentations"
],
"additionalProperties": false
}
},
"required": [
"protocolVersion",
"matchId",
"requestId",
"type",
"knownRevision",
"client"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"protocolVersion": {
"$ref": "#/$defs/__schema0"
},
"matchId": {
"$ref": "#/$defs/__schema1"
},
"type": {
"type": "string",
"const": "game.unsubscribe"
},
"subscriptionId": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
}
},
"required": ["protocolVersion", "matchId", "type", "subscriptionId"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"protocolVersion": {
"$ref": "#/$defs/__schema0"
},
"matchId": {
"$ref": "#/$defs/__schema1"
},
"requestId": {
"$ref": "#/$defs/__schema9"
},
"type": {
"type": "string",
"const": "game.command-status"
},
"commandId": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
}
},
"required": [
"protocolVersion",
"matchId",
"requestId",
"type",
"commandId"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"protocolVersion": {
"$ref": "#/$defs/__schema0"
},
"matchId": {
"$ref": "#/$defs/__schema1"
},
"requestId": {
"$ref": "#/$defs/__schema9"
},
"type": {
"type": "string",
"const": "game.command-unknown"
},
"commandId": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
}
},
"required": [
"protocolVersion",
"matchId",
"requestId",
"type",
"commandId"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"protocolVersion": {
"$ref": "#/$defs/__schema0"
},
"matchId": {
"$ref": "#/$defs/__schema1"
},
"type": {
"type": "string",
"const": "game.cursor"
},
"subscriptionId": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"revision": {
"$ref": "#/$defs/__schema3"
}
},
"required": [
"protocolVersion",
"matchId",
"type",
"subscriptionId",
"revision"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"protocolVersion": {
"type": "number",
"const": 1
},
"matchId": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"type": {
"type": "string",
"const": "game.error"
},
"requestId": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"commandId": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"code": {
"type": "string",
"enum": [
"INVALID_MESSAGE",
"UNSUPPORTED_PROTOCOL",
"UNAUTHORIZED",
"NOT_PARTICIPANT",
"STALE_REVISION",
"STALE_PHASE",
"STALE_ACTOR",
"ILLEGAL_ACTION",
"MATCH_FINISHED",
"COMMAND_ID_REUSED",
"PACKAGE_UNAVAILABLE",
"INCOMPATIBLE_CLIENT",
"RATE_LIMITED",
"INTERNAL_ERROR"
]
},
"recovery": {
"type": "string",
"enum": [
"none",
"sync",
"retry-same-command",
"reload-client",
"reauthenticate"
]
},
"messageKey": {
"$ref": "#/$defs/__schema5"
}
},
"required": ["protocolVersion", "type", "code", "recovery", "messageKey"],
"additionalProperties": false
}
],
"$defs": {
"__schema0": {
"type": "number",
"const": 1
},
"__schema1": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
},
"__schema2": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "match"
},
"revision": {
"$ref": "#/$defs/__schema3"
}
},
"required": ["kind", "revision"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "actor"
},
"phaseEpoch": {
"$ref": "#/$defs/__schema3"
},
"actorRevision": {
"$ref": "#/$defs/__schema3"
}
},
"required": ["kind", "phaseEpoch", "actorRevision"],
"additionalProperties": false
}
]
},
"__schema3": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"__schema4": {
"type": "object",
"properties": {
"type": {
"$ref": "#/$defs/__schema5"
},
"payload": {
"$ref": "#/$defs/__schema6"
}
},
"required": ["type", "payload"],
"additionalProperties": false
},
"__schema5": {
"type": "string",
"pattern": "^[a-z][a-zA-Z0-9.-]{0,79}$"
},
"__schema6": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"$ref": "#/$defs/__schema7"
}
},
"__schema7": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"type": "array",
"items": {
"$ref": "#/$defs/__schema7"
}
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"$ref": "#/$defs/__schema7"
}
}
]
},
"__schema8": {
"type": "object",
"properties": {
"id": {
"$ref": "#/$defs/__schema5"
},
"rulesVersion": {
"type": "string",
"pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$"
},
"viewSchemaVersion": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"eventSchemaVersion": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"required": [
"id",
"rulesVersion",
"viewSchemaVersion",
"eventSchemaVersion"
],
"additionalProperties": false
},
"__schema9": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
}
},
"$id": "urn:juegoland:protocol:1"
}

@ -0,0 +1,175 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"schemaVersion": {
"type": "number",
"const": 1
},
"resources": {
"type": "object",
"propertyNames": {
"$ref": "#/$defs/__schema0"
},
"additionalProperties": {
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["image", "atlas", "audio", "font", "graphic"]
},
"path": {
"type": "string",
"pattern": "^[a-zA-Z0-9_-](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9_-])?(?:\\/[a-zA-Z0-9_-](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9_-])?)*$"
},
"load": {
"type": "string",
"enum": ["catalog", "board", "phase", "lazy"]
},
"phase": {
"$ref": "#/$defs/__schema0"
},
"required": {
"type": "boolean"
},
"altKey": {
"$ref": "#/$defs/__schema0"
},
"width": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"height": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"regions": {
"type": "object",
"propertyNames": {
"$ref": "#/$defs/__schema0"
},
"additionalProperties": {
"type": "object",
"properties": {
"x": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"y": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
},
"width": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
},
"height": {
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
},
"required": ["x", "y", "width", "height"],
"additionalProperties": false
}
},
"fallback": {
"oneOf": [
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "text"
},
"key": {
"$ref": "#/$defs/__schema0"
}
},
"required": ["kind", "key"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "resource"
},
"resourceId": {
"$ref": "#/$defs/__schema0"
}
},
"required": ["kind", "resourceId"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"kind": {
"type": "string",
"const": "omit"
}
},
"required": ["kind"],
"additionalProperties": false
}
]
}
},
"required": ["kind", "path", "load", "required", "fallback"],
"additionalProperties": false
}
},
"slots": {
"type": "object",
"propertyNames": {
"$ref": "#/$defs/__schema0"
},
"additionalProperties": {
"type": "object",
"properties": {
"resourceId": {
"$ref": "#/$defs/__schema0"
},
"region": {
"$ref": "#/$defs/__schema0"
}
},
"required": ["resourceId"],
"additionalProperties": false
}
},
"back": {
"type": "object",
"properties": {
"resourceId": {
"$ref": "#/$defs/__schema0"
},
"region": {
"$ref": "#/$defs/__schema0"
}
},
"required": ["resourceId"],
"additionalProperties": false
},
"aspectRatio": {
"type": "number",
"exclusiveMinimum": 0
}
},
"required": ["schemaVersion", "resources", "slots"],
"additionalProperties": false,
"$defs": {
"__schema0": {
"type": "string",
"pattern": "^[a-z][a-zA-Z0-9.-]{0,79}$"
}
},
"$id": "urn:juegoland:resource-set:1"
}

@ -0,0 +1,720 @@
// Executable specification. Not imported by the production application.
import { z } from 'zod';
export const key = z.string().regex(/^[a-z][a-zA-Z0-9.-]{0,79}$/);
export const version = z
.string()
.regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
export const relativePath = z
.string()
.regex(
/^[a-zA-Z0-9_-](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9_-])?(?:\/[a-zA-Z0-9_-](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9_-])?)*$/,
);
export const revision = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
export const json = z.json();
export const jsonObject = z.record(z.string(), json);
export const seat = z.string().regex(/^p(0|[1-9]\d*)$/);
export const reference = z.discriminatedUnion('source', [
z.strictObject({ source: z.literal('local'), path: relativePath }),
z.strictObject({
source: z.literal('dependency'),
packageId: key,
export: key,
}),
]);
const dependency = z.strictObject({
id: key,
version,
kind: z.enum(['components', 'assets']),
});
const resource = z.strictObject({
kind: z.enum(['image', 'atlas', 'audio', 'font', 'graphic']),
path: relativePath,
load: z.enum(['catalog', 'board', 'phase', 'lazy']),
phase: key.optional(),
required: z.boolean(),
altKey: key.optional(),
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
regions: z
.record(
key,
z.strictObject({
x: z.number().int().nonnegative(),
y: z.number().int().nonnegative(),
width: z.number().int().positive(),
height: z.number().int().positive(),
}),
)
.optional(),
fallback: z.discriminatedUnion('kind', [
z.strictObject({ kind: z.literal('text'), key }),
z.strictObject({ kind: z.literal('resource'), resourceId: key }),
z.strictObject({ kind: z.literal('omit') }),
]),
});
const metadata = z.strictObject({
name: z.string().min(1).max(120),
description: z.string().min(1).max(2000),
authors: z.array(z.string().min(1)).min(1),
license: z.string().min(1),
defaultLocale: z.string().regex(/^[a-z]{2}(?:-[A-Z]{2})?$/),
locales: z.record(z.string(), relativePath),
});
const common = {
format: z.literal('juegoland.package'),
formatVersion: z.literal(1),
id: key,
version,
engineApiVersion: z.literal(1),
metadata,
dependencies: z.array(dependency),
};
export const gamePackage = z.strictObject({
...common,
kind: z.literal('game'),
classification: z.strictObject({
category: key,
tags: z.array(key),
competition: z.enum(['individual', 'teams', 'cooperative', 'solo', 'open']),
}),
players: z.strictObject({
min: z.number().int().min(1).max(64),
max: z.number().int().min(1).max(64),
}),
capabilities: z.strictObject({
hiddenInformation: z.boolean(),
simultaneousActions: z.boolean(),
deadlines: z.boolean(),
}),
components: z.record(
key,
z.strictObject({
kind: z.enum(['deck', 'dice', 'wheel', 'board', 'pieces', 'custom']),
definition: reference,
copies: z.number().int().min(1).max(100),
}),
),
rules: z.strictObject({
entry: relativePath,
version,
stateSchemaVersion: z.number().int().positive(),
viewSchemaVersion: z.number().int().positive(),
eventSchemaVersion: z.number().int().positive(),
schemas: z.strictObject({
config: relativePath,
data: relativePath,
view: relativePath,
context: relativePath,
event: relativePath,
visibleEvent: relativePath,
}),
}),
presentation: z.strictObject({
entry: relativePath,
apiVersion: z.literal(1),
bundleVersion: version,
resources: z.record(key, resource),
bindings: z.record(key, reference),
catalog: z.strictObject({ cover: key, thumbnail: key, icon: key }),
}),
});
export const libraryPackage = z.strictObject({
...common,
kind: z.enum(['components', 'assets']),
exports: z.record(key, relativePath),
});
export const packageSchema = z.union([gamePackage, libraryPackage]);
export type GamePackage = z.infer<typeof gamePackage>;
export type Package = z.infer<typeof packageSchema>;
const cardDefinition = z.strictObject({
id: key,
suit: key,
rank: z.number().int(),
attributes: jsonObject,
});
export const componentSchema = z.discriminatedUnion('kind', [
z.strictObject({
kind: z.literal('deck'),
schemaVersion: z.literal(1),
cards: z.array(cardDefinition).min(1),
}),
z.strictObject({
kind: z.literal('dice'),
schemaVersion: z.literal(1),
dice: z
.array(
z.strictObject({
id: key,
faces: z
.array(
z.strictObject({
id: key,
value: json,
weight: z.number().int().positive().max(1000000),
}),
)
.min(2),
}),
)
.min(1),
}),
z.strictObject({
kind: z.literal('wheel'),
schemaVersion: z.literal(1),
sectors: z
.array(
z.strictObject({
id: key,
weight: z.number().int().positive().max(1000000),
}),
)
.min(1),
}),
z.strictObject({
kind: z.literal('board'),
schemaVersion: z.literal(1),
layout: z.discriminatedUnion('kind', [
z.strictObject({
kind: z.literal('grid'),
rows: z.number().int().positive(),
columns: z.number().int().positive(),
}),
z.strictObject({
kind: z.literal('graph'),
cells: z
.array(z.strictObject({ id: key, neighbors: z.array(key) }))
.min(1),
}),
]),
}),
z.strictObject({
kind: z.literal('pieces'),
schemaVersion: z.literal(1),
definitions: z
.array(
z.strictObject({
id: key,
count: z.number().int().positive(),
attributes: jsonObject,
}),
)
.min(1),
}),
z.strictObject({
kind: z.literal('custom'),
schemaVersion: z.literal(1),
typeId: key,
data: jsonObject,
}),
]);
export type ComponentDefinition = z.infer<typeof componentSchema>;
export const resourceSetSchema = z.strictObject({
schemaVersion: z.literal(1),
resources: z.record(key, resource),
slots: z.record(
key,
z.strictObject({ resourceId: key, region: key.optional() }),
),
back: z.strictObject({ resourceId: key, region: key.optional() }).optional(),
aspectRatio: z.number().positive().optional(),
});
const digest = z.string().regex(/^[a-f0-9]{64}$/);
export const lockSchema = z.strictObject({
formatVersion: z.literal(1),
root: z.strictObject({ id: key, version, sha256: digest }),
resolved: z.array(z.strictObject({ id: key, version, sha256: digest })),
files: z.array(
z.strictObject({
packageId: key,
path: relativePath,
sha256: digest,
bytes: revision,
mediaType: z.string().min(1),
url: z.string().url().nullable(),
}),
),
});
export const presentationPlanSchema = z.strictObject({
id: z.string().min(1),
fromRevision: revision,
toRevision: revision,
steps: z
.array(
z.strictObject({
id: key,
effect: key,
trigger: z.discriminatedUnion('kind', [
z.strictObject({ kind: z.literal('after'), steps: z.array(key) }),
z.strictObject({ kind: z.literal('marker'), step: key, marker: key }),
]),
params: jsonObject,
emits: z.array(key),
blocking: z.boolean(),
timeoutMs: z.number().int().min(1).max(12000),
}),
)
.max(128),
});
export const result = z.strictObject({
kind: z.literal('completed'),
reasonCode: key,
participants: z.record(
seat,
z.strictObject({
outcome: z.enum(['win', 'loss', 'draw', 'neutral']),
score: z.number().finite().optional(),
rank: z.number().int().positive().optional(),
teamId: key.optional(),
metrics: jsonObject,
}),
),
});
export const matchOutcome = z.union([
result,
z.strictObject({ kind: z.literal('cancelled'), reasonCode: key }),
]);
export type MatchOutcome = z.infer<typeof matchOutcome>;
export const action = z.strictObject({ type: key, payload: jsonObject });
export const precondition = z.discriminatedUnion('kind', [
z.strictObject({ kind: z.literal('match'), revision }),
z.strictObject({
kind: z.literal('actor'),
phaseEpoch: revision,
actorRevision: revision,
}),
]);
const base = { protocolVersion: z.literal(1), matchId: z.uuid() };
const request = { ...base, requestId: z.uuid() };
const gameViewContract = z.strictObject({
id: key,
rulesVersion: version,
viewSchemaVersion: z.number().int().positive(),
eventSchemaVersion: z.number().int().positive(),
});
export const command = z.strictObject({
...base,
type: z.literal('game.command'),
commandId: z.uuid(),
expected: precondition,
action,
});
export const errorCode = z.enum([
'INVALID_MESSAGE',
'UNSUPPORTED_PROTOCOL',
'UNAUTHORIZED',
'NOT_PARTICIPANT',
'STALE_REVISION',
'STALE_PHASE',
'STALE_ACTOR',
'ILLEGAL_ACTION',
'MATCH_FINISHED',
'COMMAND_ID_REUSED',
'PACKAGE_UNAVAILABLE',
'INCOMPATIBLE_CLIENT',
'RATE_LIMITED',
'INTERNAL_ERROR',
]);
export const publicEvent = z.strictObject({
eventId: z.uuid(),
revision,
order: revision,
type: key,
payload: jsonObject,
});
export const offer = z.discriminatedUnion('kind', [
z.strictObject({
kind: z.literal('concrete'),
action,
expected: precondition,
}),
z.strictObject({
kind: z.literal('parameterized'),
actionType: key,
inputSchemaId: key,
constraints: jsonObject,
expected: precondition,
}),
]);
export const snapshot = z.strictObject({
...base,
type: z.literal('game.snapshot'),
subscriptionId: z.uuid(),
delivery: z.discriminatedUnion('kind', [
z.strictObject({ kind: z.literal('sync'), requestId: z.uuid() }),
z.strictObject({ kind: z.literal('live'), fromRevision: revision }),
]),
revision,
game: gameViewContract,
status: z.enum(['active', 'completed', 'cancelled']),
view: jsonObject,
context: jsonObject,
availableActions: z.array(offer),
outcome: matchOutcome.nullable(),
events: z.array(publicEvent),
});
export const ack = z.strictObject({
...base,
type: z.literal('game.ack'),
commandId: z.uuid(),
duplicate: z.boolean(),
receipt: z.discriminatedUnion('status', [
z.strictObject({ status: z.literal('applied'), appliedRevision: revision }),
z.strictObject({
status: z.literal('rejected'),
code: z.enum([
'STALE_REVISION',
'STALE_PHASE',
'STALE_ACTOR',
'ILLEGAL_ACTION',
'MATCH_FINISHED',
]),
observedRevision: revision,
}),
]),
});
export const wireSchema = z.union([
command,
snapshot,
ack,
z.strictObject({
...request,
type: z.literal('game.sync'),
knownRevision: revision.nullable(),
client: z.strictObject({
engineApiVersion: z.literal(1),
presentationApiVersion: z.literal(1),
presentations: z.array(gameViewContract).min(1),
}),
}),
z.strictObject({
...base,
type: z.literal('game.unsubscribe'),
subscriptionId: z.uuid(),
}),
z.strictObject({
...request,
type: z.literal('game.command-status'),
commandId: z.uuid(),
}),
z.strictObject({
...request,
type: z.literal('game.command-unknown'),
commandId: z.uuid(),
}),
z.strictObject({
...base,
type: z.literal('game.cursor'),
subscriptionId: z.uuid(),
revision,
}),
z.strictObject({
protocolVersion: z.literal(1),
matchId: z.uuid().optional(),
type: z.literal('game.error'),
requestId: z.uuid().optional(),
commandId: z.uuid().optional(),
code: errorCode,
recovery: z.enum([
'none',
'sync',
'retry-same-command',
'reload-client',
'reauthenticate',
]),
messageKey: key,
}),
]);
export type WireMessage = z.infer<typeof wireSchema>;
export type Command = z.infer<typeof command>;
export type Snapshot = z.infer<typeof snapshot>;
export type ActionOffer = z.infer<typeof offer>;
// Cross-field and cross-package rules are a second validation layer, documented
// in the specification. JSON Schema describes the structural layer only.
export function validatePackage(
value: unknown,
registry: ReadonlyMap<string, Package> = new Map(),
): Package {
const parsed = packageSchema.parse(value);
const fail = (message: string): never => {
throw new Error(`${parsed.id}: ${message}`);
};
const dependencies = new Map<string, Package>();
for (const dep of parsed.dependencies) {
if (dependencies.has(dep.id) || dep.id === parsed.id)
fail('duplicate or self dependency');
const resolved = registry.get(`${dep.id}@${dep.version}`);
if (!resolved || resolved.kind !== dep.kind)
fail(`unresolved dependency ${dep.id}@${dep.version}`);
dependencies.set(dep.id, resolved!);
}
if (!Object.hasOwn(parsed.metadata.locales, parsed.metadata.defaultLocale))
fail('missing default locale');
if (parsed.kind !== 'game') {
if (!Object.keys(parsed.exports).length) fail('library has no exports');
if (parsed.dependencies.some((dep) => dep.kind !== parsed.kind))
fail('library dependency crosses server/client boundary');
return parsed;
}
if (parsed.players.min > parsed.players.max) fail('invalid player bounds');
if (parsed.classification.competition === 'solo' && parsed.players.max !== 1)
fail('solo requires one player');
const resolve = (
ref: z.infer<typeof reference>,
expected: 'components' | 'assets',
) => {
if (ref.source === 'local') return;
const dep = dependencies.get(ref.packageId);
if (
!dep ||
dep.kind === 'game' ||
dep.kind !== expected ||
!Object.hasOwn(dep.exports, ref.export)
)
fail('invalid dependency export');
};
for (const component of Object.values(parsed.components))
resolve(component.definition, 'components');
for (const [id, binding] of Object.entries(parsed.presentation.bindings)) {
if (!Object.hasOwn(parsed.components, id))
fail(`binding without component ${id}`);
resolve(binding, 'assets');
}
const resources = parsed.presentation.resources;
for (const id of Object.values(parsed.presentation.catalog)) {
const asset = resources[id];
if (
!asset ||
!['image', 'graphic'].includes(asset.kind) ||
asset.load !== 'catalog'
)
fail(`invalid catalog resource ${id}`);
}
validateResources(resources);
return parsed;
}
function validateResources(
resources: z.infer<typeof resourceSetSchema>['resources'],
) {
const fail = (message: string): never => {
throw new Error(message);
};
for (const [id, asset] of Object.entries(resources)) {
if ((asset.load === 'phase') !== (asset.phase !== undefined))
fail(`invalid phase loading ${id}`);
if (
['image', 'atlas'].includes(asset.kind) &&
(!asset.width || !asset.height)
)
fail(`missing image dimensions ${id}`);
if (asset.kind === 'atlas' && !Object.keys(asset.regions ?? {}).length)
fail(`empty atlas ${id}`);
if (asset.regions && asset.kind !== 'atlas')
fail(`regions outside atlas ${id}`);
for (const region of Object.values(asset.regions ?? {})) {
if (
region.x + region.width > asset.width! ||
region.y + region.height > asset.height!
)
fail(`atlas bounds ${id}`);
}
if (asset.required && asset.fallback.kind === 'omit')
fail(`required resource cannot be omitted ${id}`);
const visited = new Set([id]);
let cursor = asset;
while (cursor.fallback.kind === 'resource') {
const next = cursor.fallback.resourceId;
if (visited.has(next) || !Object.hasOwn(resources, next))
fail(`fallback cycle or missing resource ${id}`);
visited.add(next);
cursor = resources[next];
}
if (asset.required && cursor.fallback.kind === 'omit')
fail(`required fallback chain ends in omission ${id}`);
}
}
export function validateRegistry(
values: unknown[],
): ReadonlyMap<string, Package> {
const registry = new Map<string, Package>();
for (const value of values) {
const parsed = packageSchema.parse(value);
const identity = `${parsed.id}@${parsed.version}`;
if (registry.has(identity))
throw new Error(`duplicate package ${identity}`);
registry.set(identity, parsed);
}
const visiting = new Set<string>(),
complete = new Set<string>();
const visit = (identity: string) => {
if (visiting.has(identity))
throw new Error(`dependency cycle at ${identity}`);
if (complete.has(identity)) return;
const value = registry.get(identity);
if (!value) throw new Error(`unresolved package ${identity}`);
visiting.add(identity);
validatePackage(value, registry);
for (const dep of value.dependencies) visit(`${dep.id}@${dep.version}`);
visiting.delete(identity);
complete.add(identity);
};
for (const identity of registry.keys()) visit(identity);
// A registry can host several releases, but one package closure must resolve
// exactly one release per package id (the lock uses packageId for its files).
for (const root of registry.values()) {
const selected = new Map<string, string>();
const resolve = (value: Package) => {
const previous = selected.get(value.id);
if (previous && previous !== value.version)
throw new Error(`conflicting dependency versions for ${value.id}`);
if (previous) return;
selected.set(value.id, value.version);
for (const dep of value.dependencies)
resolve(registry.get(`${dep.id}@${dep.version}`)!);
};
resolve(root);
}
return registry;
}
function unique(ids: string[], label: string) {
if (new Set(ids).size !== ids.length) throw new Error(`duplicate ${label}`);
}
export function validateComponent(value: unknown): ComponentDefinition {
const component = componentSchema.parse(value);
if (component.kind === 'deck')
unique(
component.cards.map((card) => card.id),
'card definition',
);
if (component.kind === 'dice') {
unique(
component.dice.map((die) => die.id),
'die',
);
for (const die of component.dice)
unique(
die.faces.map((face) => face.id),
'die face',
);
}
if (component.kind === 'wheel')
unique(
component.sectors.map((sector) => sector.id),
'wheel sector',
);
if (component.kind === 'pieces')
unique(
component.definitions.map((piece) => piece.id),
'piece definition',
);
if (component.kind === 'board' && component.layout.kind === 'graph') {
const ids = component.layout.cells.map((cell) => cell.id);
unique(ids, 'board cell');
const known = new Set(ids);
for (const cell of component.layout.cells) {
unique(cell.neighbors, 'neighbor');
if (cell.neighbors.some((id) => !known.has(id)))
throw new Error('unknown board neighbor');
}
}
return component;
}
export function validateResourceSet(
value: unknown,
requiredSlots: readonly string[] = [],
) {
const set = resourceSetSchema.parse(value);
validateResources(set.resources);
for (const slot of [
...Object.values(set.slots),
...(set.back ? [set.back] : []),
]) {
if (!Object.hasOwn(set.resources, slot.resourceId))
throw new Error('unknown slot resource');
const resource = set.resources[slot.resourceId];
if (!['image', 'atlas', 'graphic'].includes(resource.kind))
throw new Error('nonvisual slot resource');
if (
slot.region &&
(resource.kind !== 'atlas' ||
!Object.hasOwn(resource.regions ?? {}, slot.region))
)
throw new Error('unknown slot region');
}
if (requiredSlots.some((id) => !Object.hasOwn(set.slots, id)))
throw new Error('missing component artwork slot');
return set;
}
export function validateMessage(value: unknown): WireMessage {
const parsed = wireSchema.parse(value);
if (parsed.type !== 'game.snapshot') return parsed;
const fail = (message: string): never => {
throw new Error(message);
};
if (parsed.delivery.kind === 'sync' && parsed.events.length)
fail('sync must not replay events');
if (
parsed.delivery.kind === 'live' &&
parsed.revision !== parsed.delivery.fromRevision + 1
)
fail('noncontiguous live revision');
const ids = new Set<string>();
parsed.events.forEach((event, index) => {
if (
ids.has(event.eventId) ||
event.revision !== parsed.revision ||
event.order !== index
)
fail('invalid visible event order');
ids.add(event.eventId);
});
if ((parsed.status === 'active') !== (parsed.outcome === null))
fail('status/outcome mismatch');
if (parsed.outcome && parsed.outcome.kind !== parsed.status)
fail('terminal outcome mismatch');
if (parsed.status !== 'active' && parsed.availableActions.length)
fail('finished match offers actions');
if (
parsed.outcome?.kind === 'completed' &&
!Object.keys(parsed.outcome.participants).length
)
fail('empty result');
return parsed;
}
export function validatePlan(value: unknown) {
const plan = presentationPlanSchema.parse(value);
if (plan.toRevision !== plan.fromRevision + 1)
throw new Error('noncontiguous plan');
const steps = new Map(plan.steps.map((step) => [step.id, step]));
if (steps.size !== plan.steps.length) throw new Error('duplicate step');
const visiting = new Set<string>(),
done = new Set<string>();
const visit = (id: string) => {
if (visiting.has(id)) throw new Error('plan cycle');
if (done.has(id)) return;
const step = steps.get(id);
if (!step) throw new Error('unknown step');
visiting.add(id);
if (step.trigger.kind === 'marker') {
const source = steps.get(step.trigger.step);
if (!source?.emits.includes(step.trigger.marker))
throw new Error('unknown marker');
visit(step.trigger.step);
} else for (const dependency of step.trigger.steps) visit(dependency);
visiting.delete(id);
done.add(id);
};
for (const id of steps.keys()) visit(id);
return plan;
}

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"types": ["node"],
"lib": ["ES2023", "DOM"]
},
"include": ["*.ts"]
}

@ -0,0 +1,35 @@
import type {
ActionHandler,
DeepReadonly,
GameDefinition,
Position,
} from './contracts.ts';
type Config = { columns: number };
type Data = { grid: number[] };
type View = { grid: number[] };
type Drop = { type: 'drop'; payload: { column: number } };
type Event = { type: 'disc.dropped'; payload: { row: number; column: number } };
function readonlyContract(position: DeepReadonly<Position<Config, Data>>) {
// @ts-expect-error Nested arrays must be immutable at the module boundary.
position.data.grid.push(1);
// @ts-expect-error Nested configuration must also be immutable.
position.config.columns = 9;
}
function actionContract(handler: ActionHandler<Config, Data, Drop, Event>) {
handler.schema.parse({ type: 'drop', payload: { column: 3 } });
// @ts-expect-error apply is synchronous; a Promise is not a rule transition.
handler.apply = async () => ({ ok: true });
}
type Definition = GameDefinition<Config, Data, View, Drop, Event, Event>;
function definitionContract(definition: Definition) {
// @ts-expect-error Only declared action discriminants can be registered.
definition.actions.fire;
// @ts-expect-error The canonical action payload is checked by TypeScript.
const invalid: Drop = { type: 'drop', payload: { cell: 1 } };
return invalid;
}
void readonlyContract;
void actionContract;
void definitionContract;

@ -0,0 +1,330 @@
import assert from 'node:assert/strict';
import { readFile, writeFile } from 'node:fs/promises';
import { z } from 'zod';
import {
componentSchema,
gamePackage,
lockSchema,
packageSchema,
presentationPlanSchema,
resourceSetSchema,
validateComponent,
validateResourceSet,
validatePackage,
validatePlan,
validateRegistry,
validateMessage,
wireSchema,
} from './schema.ts';
const read = async (file: string) =>
JSON.parse(await readFile(new URL(file, import.meta.url), 'utf8'));
const libraries: unknown[] = await read('examples/libraries.json');
const games = await Promise.all(
['conecta4', 'hundido', 'solitario'].map((id) =>
read(`examples/${id}.game.json`),
),
);
const registry = validateRegistry([...libraries, ...games]);
const messages: unknown[] = await read('examples/wire.json');
for (const message of messages) validateMessage(message);
let negativeCases = 0;
function rejects(label: string, run: () => unknown) {
assert.throws(run, `Must reject: ${label}`);
negativeCases++;
}
function changeGame(
index: number,
edit: (game: z.infer<typeof gamePackage>) => void,
) {
const game = structuredClone(games[index]);
edit(game);
return () => validatePackage(game, registry);
}
rejects('unknown manifest field', () =>
validatePackage({ ...games[0], runtimeMagic: true }, registry),
);
rejects(
'missing rules entry',
changeGame(0, (game) => {
game.rules.entry = '';
}),
);
rejects(
'path traversal',
changeGame(0, (game) => {
game.rules.entry = '../server/secrets.ts';
}),
);
rejects(
'absolute path',
changeGame(0, (game) => {
game.rules.entry = '/server/rules.ts';
}),
);
rejects(
'empty path segment',
changeGame(0, (game) => {
game.rules.entry = 'rules//index.ts';
}),
);
rejects(
'directory used as entry',
changeGame(0, (game) => {
game.rules.entry = 'rules/';
}),
);
rejects(
'floating dependency',
changeGame(2, (game) => {
game.dependencies[0].version = '^1.0.0';
}),
);
rejects(
'missing dependency',
changeGame(2, (game) => {
game.dependencies[0].id = 'missing';
}),
);
rejects(
'missing export',
changeGame(2, (game) => {
game.presentation.bindings.mainDeck = {
source: 'dependency',
packageId: 'cards-poker',
export: 'missing',
};
}),
);
rejects(
'inherited export',
changeGame(2, (game) => {
game.presentation.bindings.mainDeck = {
source: 'dependency',
packageId: 'cards-poker',
export: 'constructor',
};
}),
);
rejects(
'wrong dependency kind',
changeGame(2, (game) => {
game.presentation.bindings.mainDeck = {
source: 'dependency',
packageId: 'playing-cards',
export: 'poker52',
};
}),
);
rejects(
'invalid player interval',
changeGame(0, (game) => {
game.players.min = 4;
}),
);
rejects(
'solo with multiple players',
changeGame(2, (game) => {
game.players.max = 2;
}),
);
rejects(
'missing cover',
changeGame(0, (game) => {
game.presentation.catalog.cover = 'missing';
}),
);
rejects(
'required asset omitted',
changeGame(0, (game) => {
game.presentation.resources['catalog.cover'].fallback = { kind: 'omit' };
}),
);
rejects(
'fallback cycle',
changeGame(0, (game) => {
game.presentation.resources['catalog.cover'].fallback = {
kind: 'resource',
resourceId: 'catalog.cover',
};
}),
);
rejects(
'atlas bounds',
changeGame(1, (game) => {
game.presentation.resources['fleet.ships'].regions!.lancha.x = 1024;
}),
);
rejects(
'phase load without phase',
changeGame(1, (game) => {
delete game.presentation.resources['sound.hit'].phase;
}),
);
rejects('duplicate package', () =>
validateRegistry([...libraries, ...games, games[0]]),
);
const cycleA = structuredClone(libraries[0]) as Record<string, unknown>;
const cycleB = structuredClone(libraries[0]) as Record<string, unknown>;
cycleA.id = 'cycle-a';
cycleB.id = 'cycle-b';
cycleA.dependencies = [{ id: 'cycle-b', version: '1.0.0', kind: 'components' }];
cycleB.dependencies = [{ id: 'cycle-a', version: '1.0.0', kind: 'components' }];
rejects('dependency cycle', () => validateRegistry([cycleA, cycleB]));
const firstVersion = structuredClone(libraries[0]) as Record<string, any>;
const secondVersion = structuredClone(firstVersion);
secondVersion.version = '2.0.0';
const parentA = structuredClone(firstVersion);
parentA.id = 'parent-a';
parentA.dependencies = [
{ id: firstVersion.id, version: '1.0.0', kind: 'components' },
];
const parentB = structuredClone(firstVersion);
parentB.id = 'parent-b';
parentB.dependencies = [
{ id: firstVersion.id, version: '2.0.0', kind: 'components' },
];
const root = structuredClone(firstVersion);
root.id = 'root';
root.dependencies = [
{ id: 'parent-a', version: '1.0.0', kind: 'components' },
{ id: 'parent-b', version: '1.0.0', kind: 'components' },
];
rejects('conflicting transitive versions', () =>
validateRegistry([root, parentA, parentB, firstVersion, secondVersion]),
);
validateRegistry([parentA, parentB, firstVersion, secondVersion]);
const message = (index: number) =>
structuredClone(messages[index]) as Record<string, any>;
const badActor = message(1);
badActor.actor = 'p1';
rejects('client cannot supply actor', () => validateMessage(badActor));
const badPolicy = message(2);
badPolicy.expected.actorRevision = -1;
rejects('negative revision', () => validateMessage(badPolicy));
const live = message(7);
const gap = structuredClone(live);
gap.revision = 3;
rejects('live revision gap', () => validateMessage(gap));
const historyOnSync = structuredClone(live);
historyOnSync.delivery = message(6).delivery;
rejects('sync replays event history', () => validateMessage(historyOnSync));
const repeated = structuredClone(live);
repeated.events.push({ ...repeated.events[0], order: 1 });
rejects('duplicate event', () => validateMessage(repeated));
const activeWithOutcome = structuredClone(live);
activeWithOutcome.status = 'active';
rejects('active match with terminal outcome', () =>
validateMessage(activeWithOutcome),
);
const terminalOffer = structuredClone(live);
terminalOffer.availableActions = [
{
kind: 'concrete',
action: { type: 'draw', payload: {} },
expected: { kind: 'match', revision: 1 },
},
];
rejects('finished match offering actions', () =>
validateMessage(terminalOffer),
);
const emptyOutcome = structuredClone(live);
emptyOutcome.outcome.participants = {};
rejects('empty completed outcome', () => validateMessage(emptyOutcome));
const transientRejection = message(5);
transientRejection.receipt.code = 'INTERNAL_ERROR';
rejects('transient failure must not become terminal receipt', () =>
validateMessage(transientRejection),
);
const cooperative = structuredClone(live);
cooperative.outcome = {
kind: 'completed',
reasonCode: 'objective.failed',
participants: {
p0: { outcome: 'loss', metrics: {} },
p1: { outcome: 'loss', metrics: {} },
},
};
validateMessage(cooperative);
assert.equal(
(live.outcome.participants.p0 as { outcome: string }).outcome,
'loss',
);
const plan = await read('examples/drop.plan.json');
validatePlan(plan);
const cyclic = structuredClone(plan);
cyclic.steps[0].trigger = { kind: 'after', steps: ['finish'] };
rejects('animation dependency cycle', () => validatePlan(cyclic));
const marker = structuredClone(plan);
marker.steps[1].trigger.marker = 'nonexistent';
rejects('animation marker missing', () => validatePlan(marker));
const components = ((await read('examples/components.json')) as unknown[]).map(
validateComponent,
);
const poker = components.find((component) => component.kind === 'deck')!;
assert.equal(poker.kind, 'deck');
if (poker.kind !== 'deck') throw new Error('missing poker fixture');
assert.equal(poker.cards.length, 52);
const repeatedCard = structuredClone(poker);
repeatedCard.cards.push(repeatedCard.cards[0]);
rejects('duplicate card definition', () => validateComponent(repeatedCard));
rejects('unknown graph neighbor', () =>
validateComponent({
kind: 'board',
schemaVersion: 1,
layout: { kind: 'graph', cells: [{ id: 'a', neighbors: ['b'] }] },
}),
);
const artwork = await read('examples/poker.resource-set.json');
validateResourceSet(
artwork,
poker.cards.map((card) => card.id),
);
const incompleteArtwork = structuredClone(artwork);
delete incompleteArtwork.slots[poker.cards[0].id];
rejects('missing card artwork', () =>
validateResourceSet(
incompleteArtwork,
poker.cards.map((card) => card.id),
),
);
const missingRegion = structuredClone(artwork);
missingRegion.slots[poker.cards[0].id].region = 'missing';
rejects('unknown artwork region', () => validateResourceSet(missingRegion));
const inheritedResource = structuredClone(artwork);
inheritedResource.back.resourceId = 'constructor';
rejects('inherited artwork resource', () =>
validateResourceSet(inheritedResource),
);
for (const [name, schema] of [
['package', packageSchema],
['protocol', wireSchema],
['component', componentSchema],
['resource-set', resourceSetSchema],
['lock', lockSchema],
['presentation-plan', presentationPlanSchema],
] as const) {
const generated = {
...z.toJSONSchema(schema, { target: 'draft-2020-12', reused: 'ref' }),
$id: `urn:juegoland:${name}:1`,
};
const file = `${name}.schema.json`;
if (process.argv.includes('--write-schemas'))
await writeFile(
new URL(file, import.meta.url),
JSON.stringify(generated, null, 2) + '\n',
);
assert.deepEqual(
await read(file),
generated,
`generated schema drift: ${file}`,
);
}
console.log(
`${registry.size} manifests, ${messages.length + 1} messages, ${negativeCases} rejection cases, ${components.length} components, 52 artwork slots, presentation plan and 6 generated schemas verified.`,
);
console.log(
'Scope: specification structure, references and invariants. Game payloads, package files and the engine runtime are separate implementation checks.',
);

@ -0,0 +1,233 @@
# Plataforma Juegoland v2: modelo y contratos de producto
**Estado:** propuesta revisada el 5 de octubre de 2026; todavía no implementada. La revisión corrige contratos, no certifica el servidor instalado. El [registro de hallazgos](platform/review-2026-10-05.md) conserva los defectos encontrados y cómo comprobar sus soluciones.
## 1. Alcance y autoridad de los documentos
La plataforma organiza identidad, acceso, catálogo, salas, comunicación y operación. El motor ejecuta reglas y proyecta información de partidas. La protección del menor es un requisito transversal: **chat desactivado para menores; para habilitarlo se exige mayoría de edad acreditada y teléfono verificado por SMS**, además de los permisos de sala. El usuario confirma también **juegos propios publicados exclusivamente por el equipo**. Ha encargado desarrollar las demás políticas contrastando referencias; su base concreta está en [políticas de producto](platform/product-policies.md), diferenciada de capacidades ya implementadas.
| Documento | Autoridad |
| ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Este modelo | Entidades, invariantes, estados, permisos y decisiones de producto. |
| [API y eventos](platform/api-and-events.md) | Rutas, sobres, recibos, errores y recuperación de los flujos de plataforma. |
| [Protección del menor](platform/child-safety.md) | Elegibilidad, verificación de teléfono/edad, restricciones de contenido, moderación y datos de protección. |
| [Políticas de producto](platform/product-policies.md) | Valores de P1–P8, cuentas, admisión, publicación interna, moderación, conservación y requisitos de activación. |
| [Arquitectura de tres capas](platform/architecture.md) | API Go, recomendación de servidor Go, frontend y adaptación/migración del runtime. |
| [Modelo del motor](game-engine-proposal.md) y [formato de juego](game-engine/format-and-protocol.md) | Paquetes, SDK de reglas y protocolo `game.*`; mantienen sus versiones propias. |
En conflicto, la autorización más restrictiva prevalece mientras se corrige el contrato. Una capacidad del paquete no concede permiso de plataforma. Las propuestas de producto no se convierten en requisitos aprobados solo por aparecer en un ejemplo. OpenAPI, esquemas de plataforma y pruebas de conformidad siguen pendientes; estos documentos no los sustituyen.
Las capas fijadas son frontend, API y servidor. La API se plantea en Go; se recomienda también Go para servidor/motor, en un monolito modular con PostgreSQL. API y dominio son capas de código que inicialmente comparten proceso y transacción. La producción y el SDK propuesto del motor siguen siendo Node/TypeScript; su adaptación a Go exige contratos y equivalencia de reglas según [arquitectura](platform/architecture.md). Una sala organiza miembros y contiene como máximo una partida; una revancha crea otra sala. `roomId` y `matchId` son distintos. No se crean microservicios por cada dominio.
```mermaid
flowchart LR
UI[Frontend Svelte] --> API[API Go]
API --> AUTH[Servidor: sesión y autorización]
AUTH --> SAFE[Política de protección]
SAFE --> PLATFORM[Catálogo, salas y comunicación]
SAFE --> ENGINE[Motor de partidas]
PLATFORM --> DB[(PostgreSQL)]
ENGINE --> DB
DB --> OUT[Outbox y publicación autorizada]
OUT --> UI
RELEASE[Equipo: publicación de juegos propios] --> ASSETS[Paquetes y recursos inmutables]
ASSETS --> UI
```
El lanzamiento inicial excluye espectadores, mensajes privados, torneos, ranking competitivo, pagos, apuestas, publicación de juegos de terceros y grupos sociales permanentes. Las relaciones privadas de supervisión y contactos autorizados sí forman parte de protección. Moderación, bloqueo, reportes y cobertura operativa **son requisitos para habilitar chat y encuentros públicos**, aunque no haya moderación automática.
## 2. Diferencias frente a la versión actual
| Área | Código actual | Objetivo v2 |
| ------------ | ------------------------------------------------ | --------------------------------------------------------------------------------------------- |
| Sala/partida | Objeto `room` y contador compartidos | Agregados y revisiones separados, transacciones coordinadas. |
| Invitaciones | Código de sala reutilizable | Token con caducidad, revocación y consumo atómico. |
| Tiempo real | Acuses y `room:state` | Contrato versionado, recibos duraderos, barreras y cursores. |
| Chat | Array en sala, máximo 1000 mensajes | Flujo paginado, moderación recuperable y acceso condicionado por protección. |
| Cuenta | Invitado convertible, PIN y correo sin verificar | Estados de cuenta, recuperación, revocación y comprobaciones independientes de teléfono/edad. |
| Catálogo | Registro manual | Proyección de paquetes publicados y política de disponibilidad/idoneidad. |
| Operación | Un Node, PostgreSQL y proxy | Outbox, límites, salud diferenciada, restauración y migración compatibles. |
Estas diferencias proceden de los módulos históricos `server/service.mjs`, `server/platform.mjs`, `server/auth.mjs` y `shared/room-lifecycle.mjs` de la aplicación anterior. La nueva implementación vive en este proyecto y no importa esos módulos. La comparación no prueba por sí sola un incidente en producción.
## 3. Modelo canónico
| Entidad | Identidad y datos | Invariantes |
| ------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Principal/cuenta | `userId`, tipo invitado/cuenta, estado, credenciales privadas | El invitado tiene identidad estable. Convertirlo conserva `userId`; iniciar sesión en otra cuenta no fusiona identidades automáticamente. |
| Perfil | `profileRevision`, alias, avatar, preferencias | Vista propia distinta de vista pública. No publica correo, teléfono, edad exacta ni clasificación de menor. |
| Sesión | Hash de token, principal, expiraciones, revocación y versión de autorización | Una cookie no es evidencia de aceptación vigente ni de permiso de chat. |
| Protección | `safetyRevision`, comprobaciones de edad/teléfono, restricciones | Privada. Autoridad única para juego, comunicación, descubrimiento y publicación de contenido. |
| Aceptación | Principal o contexto anónimo, versión y fecha, aceptación/revocación | Registro de hechos; no se borra el hecho de aceptar al revocar. No equivale a consentimiento parental o verificación de edad. |
| Publicación | `gameId`, versión, digests, disponibilidad y evaluación de contenido | El contenido es inmutable; la política de distribución puede cambiar sin alterar el paquete. |
| Sala | `roomId`, revisión, anfitrión, capacidad, visibilidad, configuración y política fijada | A lo sumo un `matchId`. No incorpora estado privado del juego. |
| Membresía | `membershipId`, `userId`, fecha de entrada, estado y asiento | Una membresía activa por principal/sala. `membershipId` no cambia si se reorganizan plazas en espera. |
| Invitación | `invitationId`, revisión, sala, hash de token, vencimiento y usos | No es membresía ni habilitación de chat. Token y UUID de sala son diferentes. |
| Partida | `matchId`, asientos `p0…`, versiones y estado del motor | El vínculo asiento-cuenta pertenece a plataforma. No cambia tras iniciar. |
| Mensaje/chat | `messageId`, autor, texto/estado y secuencia de creación; flujo de eventos | Crear y ocultar un mensaje son eventos distintos y ordenados. |
| Resultado | `matchId`, participantes, finalización o cancelación | Un resultado por partida; estadísticas reconstruibles y con procedencia. |
| Notificación | `notificationId`, destinatario, destino, lectura y revisión de bandeja | Dedupe incluye destinatario. Nunca copia chat ni datos ocultos del juego. |
| Reporte/restricción | Caso, sujeto/recurso, estado, motivo y auditoría de acceso | Permisos de moderación separados de jugar o ser anfitrión. |
| Outbox | Evento, agregado, revisión, destinatario/proyección y entrega | Garantía de publicación recuperable; no sustituye un registro de auditoría. |
| Auditoría | Actor, operación, motivo, fecha y acceso restringido | Retención y permisos propios; evita payloads privados por defecto. |
UUID para entidades de plataforma y partidas. Excepciones: `gameId`/`packageId` son identificadores del formato de paquetes, los asientos del motor son `p0…`, las versiones son las del contrato del motor y los digests son SHA-256. Fechas UTC con `Z`; contadores enteros seguros JSON no negativos, sin reutilización ni vuelta a cero dentro de un agregado.
El servidor deriva actor, asiento, rol, fecha y resultado. Un cliente puede identificar un **destinatario** en una operación autorizada, por ejemplo `targetMembershipId` al transferir anfitrión; eso no le permite elegir el actor ejecutor.
## 4. Ciclo de sala y partida
| Estado de sala | Significado | Operaciones |
| -------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------- |
| `waiting` | Sin partida todavía | Entrar/salir, configurar, listo, colores, invitar, cancelar; chat solo si el principal es elegible. |
| `active` | Partida activa asociada | Reglas, retirada y cancelación por política; chat elegible. |
| `completed` | Partida completada con resultado | Lectura autorizada e historial; chat de solo lectura para adultos elegibles. |
| `cancelled` | Cancelada antes o durante el juego | Motivo y lectura autorizada; no victorias/derrotas inventadas. |
Estados terminales no se reabren. Al crear se fijan paquete, dependencias y política de sala. El cliente indica el digest que eligió; si ya no se puede crear con él se rechaza, en lugar de cambiarle las reglas. Antes del inicio se puede cambiar capacidad/configuración admitida por el paquete, pero no el juego o su versión; eso exige otra sala.
La capacidad debe cumplir `players.min ≤ capacity ≤ players.max`; un paquete de 1–4 jugadores también puede iniciar una sala de uno. La base P1 exige todas las plazas configuradas ocupadas y todos listos. Cada entrada, salida o cambio de configuración invalida los «listo» anteriores; un cambio de color invalida al menos el del afectado. Las asignaciones se validan con el paquete.
Inicio atómico: comprobar permisos para jugar, recursos/versiones disponibles y condiciones de inicio; fijar asientos/configuración; crear partida y posición inicial; guardar revisiones y publicaciones. Un fallo de `setup` revierte toda la operación, incluido el último «listo». La revisión inicial de partida es 0. La sala aumenta su revisión una vez por transacción con cambio efectivo.
Salida del anfitrión en espera: P2 transfiere al miembro elegible restante con menor `joinedAt`, desempate por `membershipId`, o cancela si no queda elegible. En salas protegidas solo puede ser anfitrión un responsable adulto autorizado. La transferencia revoca las invitaciones activas del anterior anfitrión. Cancelar deliberadamente y salir son acciones distintas. En partida el anfitrión no cambia reglas ni expulsa a un rival.
Una retirada ordinaria usa `onLifecycle(participant.withdrawn)` del motor. La plataforma no decide que el último jugador gana. Excepción de protección explícita: la salida voluntaria del anfitrión responsable en una partida protegida activa cancela mediante el servicio de núcleo sin resultado competitivo, igual que una revocación de tutela/contacto que impida continuar. Ese responsable ocupa una plaza; supervisión sin plaza no está incluida en el primer perfil. Un participante retirado no se reemplaza ni reingresa; conserva solo las proyecciones históricas que autoricen reglas y protección. Una salida en espera revoca la membresía; una entrada posterior crea otra membresía. Cerrar pestaña o perder conexión nunca es una retirada.
### Concurrencia y enlace con el motor
Una mutación de sala exige revisión de sala; **retirada y cierre en partida exigen además revisión de partida**. Una jugada concurrente invalida la confirmación obsoleta de salida/cierre. Chat, presencia y lectura de notificaciones no cambian revisiones de sala o juego. Un no-op y un rechazo tampoco las incrementan.
Toda escritura sobre una sala, incluida `game.command`, entra por la misma unidad de trabajo: contexto de autorización coherente, bloqueo de sala, partida si existe, invitación/chat y demás registros en orden fijo. La revocación de autorización se serializa con esas operaciones; una operación posterior a la revocación confirmada no se admite. No se hace red ni se espera al usuario dentro de la transacción. El orden debe ser idéntico en cron, HTTP y sockets para reducir interbloqueos; PostgreSQL documenta esta [disciplina de bloqueo](https://www.postgresql.org/docs/current/explicit-locking.html).
Resultado terminal, estado de sala, estado del motor, recibo, plazos y outbox se confirman juntos. Una acción puede cambiar la partida sin modificar la sala; al finalizar o retirarse alguien cambia también la sala. El adaptador de persistencia del motor debe participar en esta misma transacción, no abrir otra que confirme por separado. La cancelación de plataforma requiere un servicio interno del núcleo que cierre la posición y los plazos de forma válida; no se añade de forma implícita un evento nuevo al SDK existente.
Los mensajes de ambos flujos pueden llegar en distinto orden. El cliente puede ver temporalmente sala activa y partida terminada sin que la base esté corrupta. Usa `matchRef.minimumRevision` como barrera de coordinación y sincroniza el flujo atrasado; nunca modifica resultados para hacerlos coincidir.
### Inactividad y fallos operativos
La política se fija con versión y plazos al crear la sala. P3 adopta 10 minutos sin progreso para cierre solicitado y 24 horas para automático en el perfil casual; espera pública 30 minutos y privada 24 horas desde creación. La publicación de cada juego comprueba su adecuación y fija perfil. Se excluyen incidentes de servicio confirmados y se reconcilian plazos antes de reanudar cierres, como especifican las [políticas](platform/product-policies.md#5-salas-invitaciones-y-continuidad--p1p4).
En espera, `expiresAt` se calcula desde la creación; chat/presencia no lo prolongan. En partida, `lastProgressAt` cambia solo con transiciones efectivas del motor, no por chat, consultas, errores o conexiones. Para cierre solicitado, el núcleo comprueba bajo bloqueo que el solicitante activo no está habilitado en `flow.actors` y que queda al menos otro actor habilitado; una fase automática no habilita ese cierre. Solo se publica al solicitante la capacidad de cerrar, nunca actores o etapas privados. Juegos con otra semántica requieren una política explícita, sin consultar campos particulares como `state.turn`.
Un trabajo vencido revalida fecha, generación, revisión y estado bajo bloqueo. Si una jugada se confirmó antes, no aplica un cierre antiguo. Los plazos propios del juego pertenecen al motor. Falta de módulo, corrupción o indisponibilidad se expresan como estado operativo `blocked` separado del resultado; no convierten una avería en derrota.
## 5. Cuenta, sesión y autorización
Tipo de identidad, rol en sala y protección son ejes independientes. Un invitado puede ser anfitrión de su juego individual; el perfil inicial reserva multijugador a adultos acreditados o cuentas tuteladas con admisión protegida. Los permisos se calculan por principal + estado de cuenta + aceptación + protección + recurso + rol/fase, nunca por una columna «cuenta» que anule otra de «anfitrión».
| Operación | Condición mínima |
| ----------------------------- | ------------------------------------------------------------------------------------------------ |
| Catálogo/resumen de sala | Publicable y apto para el perfil de protección; no incluye personas menores identificables. |
| Crear/entrar/jugar | Sesión válida, aceptación vigente, cuenta habilitada, juego y entorno permitidos. |
| Vista de sala/partida | Membresía actual o participación histórica autorizada; proyección por destinatario. |
| Chat, incluido historial | Mayoría de edad acreditada + teléfono SMS vigente + sin restricción + autorización de sala. |
| Configurar/transferir/invitar | Anfitrión en espera y política de protección; la invitación no permite saltar estas condiciones. |
| Consultar recibo de salida | El principal que lo emitió, aunque ya no sea miembro; solo resultado mínimo de su operación. |
| Moderar/publicar paquetes | Permisos administrativos específicos, auditoría y acceso mínimo; ser anfitrión no los concede. |
Estados de cuenta: `pending-email`, `active`, `restricted`, `deletion-pending`, `deleted`. Cuentas infantiles creadas por responsable usan el canal verificado de este y su flujo de acceso propio. Las restricciones son capacidades concretas, por ejemplo no comunicar o no crear salas. El bloqueo total revoca sesiones. La eliminación no hace desaparecer asientos de partidas de otros usuarios: desvincula/seudonimiza lo necesario según política, sin presentar esa seudonimización como anonimato.
Conversión invitado-cuenta conserva identidad después de acreditar la nueva credencial, evita correos duplicados con restricción única y rota/revoca sesiones. Iniciar sesión en una cuenta existente conserva dos identidades separadas hasta un eventual flujo explícito de vinculación; nunca se apropian partidas solo por escribir el mismo correo.
Sesiones: token opaco, hash servidor, cookie `HttpOnly; Secure; SameSite=Lax; Path=/` sin dominio compartido; expiración absoluta e inactiva impuestas en servidor. [P6](platform/product-policies.md#3-identidad-credenciales-y-recuperación--p6) define credenciales, recuperación y plazos por tipo de sesión. Login/recuperación, cambio de credencial/teléfono, suspensión, logout y revocación de condiciones tienen efectos explícitos sobre sesiones y sockets. Los latidos no prolongan indefinidamente una sesión. Véase la referencia de [gestión de sesiones OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html).
Aceptar condiciones, acreditar edad, verificar teléfono y obtener autorización parental son registros distintos. Revocar condiciones invalida las capacidades protegidas en todas las sesiones, pero mantiene disponibles logout, nueva aceptación, gestión de privacidad y reporte de seguridad. Una aceptación anónima se vincula a un contexto de navegador; al crear identidad se registra la atribución, sin tomar cualquier cookie como consentimiento del titular.
La política de [protección](platform/child-safety.md) revalida acceso en peticiones, suscripción y antes de publicar. Una restricción cancela suscripciones afectadas y ordena eliminar cachés privadas. Los datos ya vistos no pueden retirarse de la memoria de una persona: se garantiza impedir nuevas entregas y retirar copias controladas por la aplicación.
## 6. Invitaciones y admisión
Token aleatorio de al menos 128 bits; hash para validación, fecha, revisión, revocación y `maxUses`. P4 fija 24 h y un uso por defecto, máximo siete días y siempre limitada por vida de sala y plazas disponibles. Cuotas en las políticas. Admitir a una persona consume un uso en la misma transacción que crea su membresía; reintentar o resolver la invitación no consume usos. Entrar ya siendo miembro no ocupa otra plaza. No se reutiliza un token revocado.
Capacidad, prohibiciones entre personas, protección, anfitrión y token se revalidan bajo bloqueo. Una invitación es una capacidad para solicitar entrada, no consentimiento parental, identidad verificada ni permiso para chat. Al empezar/terminar/cancelar se deshabilitan nuevas entradas. Una transferencia de anfitrión sigue P2.
Los enlaces no llevan tokens al registro de acceso: la propuesta usa fragmento en la página de invitación y resolución por POST con cuerpo excluido de logs, `no-store` y `no-referrer`. No se cargan analíticas o terceros en esa pantalla. Un token inválido devuelve un error genérico; razones detalladas de revocación se reservan al propietario autorizado.
Mostrar el token una vez y guardar solo su hash impedía recuperar una respuesta perdida. Se corrige con una copia cifrada de la respuesta secreta, asociada al principal/comando y con ventana de recuperación de 24 h; fuera de esa ventana solo se revela el identificador, y el anfitrión puede rotar mediante una intención nueva. El hash permanente no se sustituye por texto claro. Esto no habilita recuperar tokens de otra cuenta ni renovar su vigencia.
## 7. Catálogo, publicación y medios
Solo el equipo incorpora y publica juegos propios mediante el flujo interno de [publicación](platform/product-policies.md#8-publicación-interna-de-juegos). No hay publicadores externos ni endpoint de subida de juegos para usuarios. La validación del paquete del motor se mantiene: archivos, rutas, dependencias, hashes, licencias, recursos y pruebas. La plataforma añade revisión de idoneidad de contenido, idioma, accesibilidad y exposición de texto/medios de usuarios. No se consideran secretos los archivos públicos de un paquete; reglas privadas tienen `url: null` en el lock.
En el alcance inicial solo se publican en distribución pública recursos revisados y aptos para menores. Ocultar una ficha por edad no protege archivos accesibles por URL. Incorporar contenido restringido por edad exigiría otro perfil de distribución con autorización real y caché adecuado; no se habilita mediante una simple etiqueta del catálogo.
`published` permite crear; `retired` impide nuevas salas y permite completar las ya fijadas si los permisos siguen vigentes. Metadatos y recursos antes públicos no pasan a ser privados por retirar la versión. `quarantined` bloquea nuevas ejecuciones por incidencia; las partidas afectadas quedan operativamente bloqueadas hasta una resolución explícita, sin sustitución silenciosa de reglas. La plataforma nunca sirve una distribución privada de reglas como recurso del navegador.
Avatares subidos son un flujo independiente: límite de bytes/píxeles, formatos permitidos, decodificación real, eliminación de metadatos, nueva codificación y revisión antes de publicar. No se aceptan URLs remotas arbitrarias ni SVG/HTML de usuarios. El cliente referencia un `mediaId` propio en estado `ready`; menores y edad desconocida usan presets aprobados. El contenido publicado queda sujeto a retirada/moderación aunque su URL haya estado cacheada.
## 8. Chat, presencia y avisos
Menores y edad desconocida no pueden enviar, leer, suscribirse, recuperar ni recibir fragmentos o audio del chat. Verificar SMS nunca habilita por sí solo la comunicación. La base de políticas desactiva comunicación para todos en salas protegidas/mixtas; nombres, avatares e invitaciones visibles a menores usan proyecciones seguras. El tipo de admisión/comunicación se fija al crear la sala y no se eleva al salir un menor.
El chat usa texto plano y no adjuntos en esta versión. Cada creación y cada ocultación produce un evento con secuencia nueva; una ocultación mantiene `messageId` y sustituye su texto por una lápida. La sincronización entrega una cola reciente acotada; el historial se pagina aparte. La revisión de moderación impide que una página antigua resucite texto ocultado.
La membresía fija la secuencia de entrada; no da acceso a chat anterior. Salir en espera elimina acceso al chat de esa membresía. En una retirada en partida, si el adulto sigue siendo elegible, su lectura queda limitada al intervalo entre admisión y retirada, con redacciones posteriores aplicadas; no recibe el flujo vivo. El chat terminal es de solo lectura; mensajes se purgan a los 30 días desde su creación según P5. El acceso histórico también se revoca si cambia la protección.
Presencia es por sala y principal, agregada entre pestañas, sin autoridad sobre abandono, listo o plazos. No incluye menores/edad desconocida en listados públicos. Una observación caducada se muestra como desconocida; los valores de TTL/latido figuran en el contrato de red.
Avisos personales se derivan de cambios confirmados. La clave de deduplicación incluye destinatario, tipo, recurso y transición semántica: «te toca» al pasar a poder actuar, no en cada revisión ajena. Cada cambio de lectura también incrementa `inboxRevision` para sincronizar pestañas. El aviso no contiene texto de chat, teléfonos, edad ni información secreta; abrir el destino vuelve a autorizar. Correo/push de actividad y marketing están deshabilitados inicialmente; correo transaccional de cuenta/seguridad tiene finalidad propia. `lobby.changed` solo se genera por cambios en la proyección pública, nunca por actividad de salas privadas.
## 9. Datos, moderación y resultados
El resultado registrado es inmutable para operaciones ordinarias; una corrección administrativa excepcional añade una enmienda auditable y reconstruye estadísticas, sin reescribir silenciosamente el historial. El resultado público se obtiene por proyección: no copia métricas/equipos privados del motor. Cancelaciones no cuentan como victoria/derrota; resultados `neutral` y cooperativos se conservan correctamente.
La [matriz de conservación P8](platform/product-policies.md#9-datos-conservación-y-borrado--p8) fija plazos, purga y excepciones por categoría. «Inmutable» no significa conservar identificadores personales para siempre. Las purgas se propagan a cachés y medios; una restauración reaplica marcas de borrado/restricción antes de abrir tráfico. Faltan ejecución, responsables y validación de las bases por finalidad antes de producción.
Reportes y bloqueos están descritos en [protección](platform/child-safety.md). Son accesibles aunque el usuario no tenga chat, ni SMS, o haya revocado condiciones. La consola restringida necesita responsables, estados, tiempos de atención, apelación y auditoría antes de habilitar comunicación. Un botón sin personal/procedimiento no satisface este requisito.
## 10. Operación, rendimiento y experiencia
PostgreSQL confirma cambios y outbox juntos. El publicador entrega al menos una vez; los clientes deduplican y detectan pérdidas por revisión/cursor. Socket.IO preserva orden de mensajes que llegan, pero no garantiza por defecto recuperar todos los perdidos: la recuperación es obligación de nuestra aplicación ([documentación oficial](https://socket.io/docs/v4/delivery-guarantees/)). La outbox conserva la proyección de su revisión, no recompone un estado nuevo etiquetándolo como antiguo. Antes de enviar se vuelven a aplicar permisos y redacciones vigentes.
Se acotan mensajes, páginas, conexiones, suscripciones y buffers. Un cliente lento recibe exigencia de resincronización o desconexión recuperable; nunca hace crecer memoria sin límite. El almacén de archivos sigue siendo solo de desarrollo. Varias instancias requieren coordinación de autorización, locks, outbox y fanout; si se mantiene long-polling, también su estrategia de afinidad.
HTTP mutante exige origen permitido y CSRF vinculado a sesión/contexto; sockets verifican ambos en handshake. La IP reenviada solo se confía a proxies configurados. Logs excluyen tokens, teléfono/OTP, texto del chat, pruebas de edad y estado privado. CSP, carga de medios, redacción de logs y límites de SMS se comprueban por entorno.
`/live` comprueba proceso; `/ready` comprueba base de datos, migración compatible y recursos imprescindibles. Al drenar se deja de admitir trabajo, se completan transacciones y se conserva la outbox; el reinicio no ejecuta de nuevo reglas confirmadas. Proxy, backend, frontend/SSR, PostgreSQL y verificador externo tienen métricas separadas. P8 propone RPO ≤5 min, RTO ≤60 min, copia diaria + WAL continuo, ventana de 35 días y simulacro mensual; deben configurarse, medirse y asignarse responsables antes del despliegue v2.
Objetivos propuestos: disponibilidad 99,9 % mensual medida desde fuera del proxy; p95 de operaciones dentro del servidor <500 ms para lectura y <1 s para recibo; tras 30 s de corte, sincronización <5 s desde restablecer red. Son objetivos, no resultados observados. Antes de aceptarlos se fija carga reproducible, concurrencia, volumen, hardware y criterios de error; la latencia del SMS se mide aparte.
La interfaz mantiene navegación a 320 CSS px, zoom, orientación, teclado virtual y diálogos dentro del viewport. El tablero puede necesitar navegación bidimensional accesible, pero no estrechar toda la página. Objetivo [WCAG 2.2 AA](https://www.w3.org/TR/WCAG22/), con teclado, foco, alternativa textual, movimiento reducido y control de sonido. Los avisos para menores usan lenguaje comprensible, sin inducir a aportar teléfono/edad/documentos para desbloquear una función prohibida. Mensajes localizables y datos UTC, sin reglas basadas en textos.
## 11. Decisiones confirmadas y base de políticas
Confirmado por el usuario: protección transversal; chat desactivado para menores; SMS obligatorio para cualquier persona habilitada para chat; solo juegos propios y publicación interna; tres capas con API en Go. Requisito técnico derivado: edad desconocida no cuenta como adulta; SMS acredita posesión de número, no edad o identidad civil. Go para servidor/motor es recomendación documentada, pendiente de decisión técnica y prototipo de equivalencia.
| ID | Base desarrollada en políticas | Dependencia real antes de activar |
| --- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| P1 | Inicio automático con capacidad llena y todos listos. | Concurrencia/configuración probadas por juego. |
| P2 | Transferencia a miembro elegible más antiguo y revocación de invitaciones anteriores. | Migración de comportamiento v1 y permisos de salas protegidas. |
| P3 | Espera 30 min pública/24 h privada; inactividad casual 10 min/24 h, excluyendo incidentes confirmados. | Perfil validado por juego y reconciliación de tareas. |
| P4 | Invitación 24 h, un uso por defecto, máximo 7 días acotado a sala; cuotas y contactos autorizados. | Implementación atómica de admisión y protección. |
| P5 | Chat solo adulto en sala adulta, solo lectura al terminar; mensajes 30 días. | Moderación, cobertura, SMS y purga comprobados. |
| P6 | Correo/contraseña robusta, recuperación, sesiones acotadas, MFA administrativo; sin PIN remoto. | Proveedor de correo, contratos ejecutables y migración segura. |
| P7 | Perfil España; propuesta de cuentas tuteladas 6–17 y alta autónoma adulta; prueba de edad separada de tutela/SMS. | Verificador, procedimiento de tutela, revisión territorial y pruebas de accesibilidad/corrección. |
| P8 | Matriz de datos, objetivos RPO/RTO/carga, presupuesto SMS limitado y responsabilidades. | Recursos/personas/proveedores configurados, costes autorizados y ensayos medidos. |
Hasta implementar y validar las dependencias de P7, la modalidad segura no habilita cuentas infantiles sociales ni comunicación por suposición. No hay un interruptor de interfaz que convierta una declaración en mayoría de edad acreditada. Las referencias de protección sirven para diseño; no se afirma conformidad legal sin determinar ámbito y revisar la implementación.
## 12. Migración y aceptación
1. Resolver políticas bloqueantes y amenazas de privacidad/abuso; producir esquemas de plataforma, OpenAPI y casos de conformidad con una única fuente de tipos.
2. Migrar con mapeo duradero `oldRoomId → roomId + matchId`, conservando versiones de reglas y participantes. No ejecutar de nuevo `setup` sobre partidas existentes.
3. API v1 y v2 escriben a través de la misma autoridad/transacción; no hay doble escritura independiente. Invitaciones antiguas pasan por autorización v2; clientes antiguos sin protección suficiente deben actualizar antes de comunicar.
4. Ensayar migraciones de expansión/contracción con copia anonimizada: datos activos, historial, privacidad, chat y consentimientos. El rollback de código requiere esquema compatible; nunca deshace a ciegas una migración destructiva.
5. Migrar juegos gradualmente y retirar adaptador solo con criterio de uso/capacidad de recuperación, no por fecha supuesta.
| Caso de aceptación | Resultado obligatorio |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Dos entradas para la última plaza o invitación de un uso | Una admisión; consumo y membresía atómicos. |
| Listo frente a cambio de configuración/salida | Sin inicio con aceptación obsoleta; una sola partida. |
| Jugada frente a retirada/cierre | Se revalida también la revisión de partida; resultado coherente. |
| Commit seguido de caída antes del ack | Mismo recibo y una sola aplicación al reintentar. |
| Consulta del recibo tras salir | El autor recupera el resultado sin reabrir acceso a sala/chat. |
| Moderación frente a página de historial retrasada | El texto oculto no reaparece; nueva secuencia/revisión. |
| Revocación de teléfono/edad/permiso en otra pestaña | Cese de entregas, cierre de suscripción y eliminación de caché controlada. |
| Menor llama HTTP/WS o recupera historial/avisos | Ningún contenido de chat; incluye clientes v1. |
| Rotación de invitación con respuesta perdida | Recuperación acotada del mismo secreto o rotación explícita, sin duplicar invitación. |
| Flujos sala/partida llegan en orden inverso | Recuperación con barrera, sin falso error de corrupción. |
| Reinicio, cliente lento o pérdida del último evento | Buffers limitados, cursores y resincronización verificables. |
| Restauración de copia anterior a un borrado/restricción | Se reaplican marcas antes de servir información. |
Las pruebas anteriores son requisitos pendientes de implementación. La comprobación actual de estos Markdown verifica enlaces, estructura y ejemplos, no comportamiento del servicio.

@ -0,0 +1,330 @@
# API y eventos de plataforma — propuesta v1 para Juegoland v2
**Estado:** borrador revisado el 5 de octubre de 2026, no implementado. La revisión introduce cambios incompatibles respecto del borrador anterior; no implica un cambio de protocolo desplegado. Se completa con el [modelo](../platform-spec-v2.md), [protección del menor](child-safety.md) y [registro de revisión](review-2026-10-05.md). El protocolo `game.*` conserva su [contrato propio](../game-engine/format-and-protocol.md).
## 1. Convenciones y autorización
La API se implementará en Go según la dirección del usuario. Los contratos de dominio no dependen del lenguaje. [Arquitectura](architecture.md) recomienda API y servidor Go en un backend modular; portar reglas y cambiar Socket.IO por un binding WebSocket son trabajos separados, aún sin implementar. Este documento conserva el perfil Socket.IO explícito hasta versionar y probar su sustituto. Las [políticas de producto](product-policies.md) concretan P1–P8 y son la referencia para plazos y admisión.
HTTP nuevo bajo `/api/v2`. `/api` anterior debe pasar por las mismas políticas durante la migración. JSON UTF-8, fechas UTC con `Z`, UUID para entidades de plataforma; `gameId`, versiones, digests y asientos siguen el formato del motor. Revisiones son enteros seguros no negativos.
Las solicitudes tienen esquemas cerrados por versión. Se rechazan campos desconocidos, claves JSON duplicadas, Unicode inválido, números no finitos y exceso de bytes/profundidad antes de ejecutar. Claves duplicadas requieren validación en el decodificador de texto del transporte: Zod/JSON Schema sobre el objeto ya parseado no las detectan. Las respuestas permiten extensiones opcionales compatibles; tipos/códigos nuevos requieren una estrategia de fallback o una nueva versión.
| Requisito de acceso | Significado |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| Público seguro | Proyección para anónimo/edad desconocida, sin información social no revisada. |
| Sesión | Principal válido; no basta con que exista una cookie. |
| Juego | Sesión, cuenta y condiciones habilitadas, membresía/rol y política de contenido/admisión. |
| Chat | Juego + mayoría de edad acreditada + SMS vigente + sin restricción de comunicación/bloqueo; también para lectura e historial. |
| Propietario | Principal dueño del recurso; nunca un `userId` autoritativo suministrado en el cuerpo. |
| Seguridad | Reportes, bloqueo, logout y gestión de datos siguen accesibles sin chat/SMS; las condiciones nuevas no impiden reportar o salir. |
Los menores y edades desconocidas no reciben chat por ninguna ruta, socket o adaptador. Ser anfitrión, tutor o tener un token no cambia esto. Los DTO no publican teléfono, prueba de edad, condición de menor ni causa privada de restricciones de terceros.
HTTP usa cookie opaca; mutaciones y resolución de secretos exigen origen permitido y token CSRF ligado al contexto de navegador/sesión. Sockets exigen origen y CSRF en handshake además de autenticación. `bootstrap` emite un contexto anónimo aleatorio para vincular solicitudes iniciales; no acepta uno inventado por el cliente. Tras cambiar de identidad se rota ese contexto.
Éxito de lectura: `{ requestId, data }`. Éxito de mutación: `{ requestId, commandId, duplicate, receipt, data? }`; `data` es una proyección autorizada en ese momento, no contenido privado guardado para repetir sin reautorizar. `requestId` lo genera el servidor en HTTP; el `requestId` de consulta socket es UUID del cliente para correlación y no sustituye la traza interna.
## 2. Idempotencia y recibos
Mutaciones de dominio HTTP llevan `Idempotency-Key` UUID y `issuedAt` UTC en el cuerpo. Los comandos socket llevan `commandId` e `issuedAt`. Un registro común de plataforma se identifica por **principal + commandId**, independiente del transporte. La huella incluye operación, recurso, precondiciones, `issuedAt` y payload validado con [JCS](https://www.rfc-editor.org/rfc/rfc8785.html); excluye cookie, CSRF y datos de transporte. Los comandos `game.*` conservan su registro y forma propios.
No se conserva el JSON canónico en claro dentro del recibo. Si incluye OTP, teléfono o token, la huella se calcula con HMAC y clave separada para impedir comprobaciones por diccionario sobre secretos cortos. Los errores y trazas tampoco incorporan esos valores.
Orden obligatorio: autenticar, buscar recibo propio, comparar huella, comprobar autorización actual para datos de respuesta y, si es nuevo, autorizar la operación y validar precondiciones en la transacción. Una restricción de membresía posterior no impide recuperar el recibo mínimo de una salida propia. Nunca se devuelve una vieja instantánea privada al repetir un comando.
La ausencia de recibo no reserva la clave: solicitudes simultáneas se serializan mediante restricción única y la misma transacción del cambio. Repetición exacta devuelve recibo idéntico con `duplicate: true`; otro contenido da `COMMAND_ID_REUSED` sin sobrescribir el anterior. Rechazos de negocio definitivos se guardan; fallo de infraestructura, límite o autorización no consumen un comando como rechazo de negocio.
```json
{
"status": "applied",
"resource": {
"kind": "room",
"id": "1093543c-d383-437c-a427-251d39d63e42"
},
"changed": true,
"revisions": { "room": 6, "match": null }
}
```
Recibo rechazado: `{ status: "rejected", code, resource, observedRevisions }`. Chat aplicado añade `messageId` y `chatSequence`, sin texto. No-op devuelve `changed: false`, revisión actual y ningún incremento. Un ack de creación de desafío SMS confirma que se aceptó la tarea, no que se verificó el teléfono.
Para comandos nuevos, `issuedAt` no puede tener más de 24 h ni estar más de 5 min en el futuro; `bootstrap.serverTime` permite corregir reloj. Si existe recibo, se consulta antes de caducidad/precondiciones. Los recibos de sala/chat duran tanto como sus datos; el resto al menos 24 h. Tras purgarlos, el sobre original ya caducado se rechaza en vez de aplicarse de nuevo. Una intención revisada por el usuario puede crear otro UUID; el cliente nunca cambia UUID o fecha automáticamente para reintentar.
`GET /api/v2/commands/{commandId}` y `platform.command-status` devuelven `found` con recibo mínimo propio, o `unknown`. `unknown` no prueba que un envío retrasado no vaya a confirmarse. Se reenvía exactamente el mismo sobre mientras sea admisible. No se expone el resultado de otro principal.
### Operaciones que emiten secretos
Login, alta y recuperación no reutilizan ciegamente el mecanismo de recibos de dominio ni guardan credenciales en su huella/log. P6 fija correo/contraseña, recuperación y registro de intento de autenticación propio: respuesta cifrada recuperable durante 5 min, ligada al contexto y prueba original, sin reemitir sesiones revocadas. La sección 9 concreta flujos; faltan esquemas ejecutables y pruebas de emisión concurrente.
Alta de invitado debe vincularse al contexto anónimo emitido en `bootstrap` y a su intento, con unicidad para no crear dos identidades al perder la respuesta. Repetir solo puede recuperar esa emisión dentro de su ventana, no una identidad convertida posteriormente en cuenta. Una emisión de sesión recuperable usa respuesta cifrada, vinculada al contexto original, durante un máximo de 5 min; luego exige el flujo de acceso correspondiente. El invitado queda limitado a juego individual hasta completar requisitos.
Creación/rotación de invitación almacena hash para validar el token y una copia cifrada de respuesta durante 24 h para el mismo principal/comando. La consulta de estado nunca devuelve el secreto. Un reintento autorizado durante la ventana puede recuperarlo; después devuelve recibo e `invitationId` con `secretAvailable: false`. Rotar después es una intención nueva y revoca el token anterior. No se prolonga caducidad al repetir.
## 3. HTTP
Las tablas indican operaciones mínimas y campos relevantes. Las políticas de credenciales, supervisión y plazos personales están desarrolladas en P6–P8; el proveedor de edad, los esquemas ejecutables y procedimientos de evidencia deben concretarse antes de habilitarlas.
### Sesión, cuenta y protección
| Método y ruta | Solicitud / datos | Acceso |
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `GET /api/v2/bootstrap` | `serverTime`, versiones, límites, sesión propia mínima, condiciones y capacidades de protección | Público seguro, `no-store`; CSRF propio fuera de vistas públicas. |
| `POST /api/v2/terms/accept` | `version`, `accepted: true` → recibo | Contexto propio o sesión; versión exacta. |
| `POST /api/v2/terms/revoke` | Versión/referencia de aceptación → registro revocado | Propietario; invalida capacidades protegidas de todas sus sesiones. |
| `POST /api/v2/auth/guest` | Nombre seguro → identidad/cookie | Contexto anónimo, aceptación; sin reservar asiento. |
| `POST /api/v2/auth/register`, `/api/v2/auth/login` | Alta pendiente / credencial → sesión con capacidades reales | P6 y sección 9; nunca se acepta edad acreditada del cliente. |
| `POST /api/v2/auth/logout` | Revocar sesión actual | Seguridad; puede repetirse sin sesión vigente. |
| `GET /api/v2/me/sessions`, `POST /api/v2/me/sessions/{sessionId}/revoke` | Sesiones propias mínimas / revocación | Propietario; no devuelve hashes/tokens. |
| `GET /api/v2/me` | Perfil y preferencias propias con revisión | Propietario; separado del perfil público. |
| `PATCH /api/v2/me/profile` | `expectedRevision`, campos permitidos, `avatarMediaId?` | Propietario y políticas de contenido. |
| `GET /api/v2/me/safety` | Estados mínimos de edad/teléfono, capacidades y `safetyRevision` | Propietario; no expone pruebas ni número completo. |
| `POST /api/v2/me/age-checks` | Inicio de comprobación → referencia de proveedor | Cuenta; umbral acreditado por proveedor evaluado, nunca prueba autodeclarada. |
| `POST /api/v2/me/phone-challenges` | `phoneNumber`, finalidad → desafío y tiempos, HTTP 202 | Cuenta adulta acreditada; acción explícita, límites SMS. |
| `POST /api/v2/me/phone-challenges/{challengeId}/verify` | `code` → estado propio | Principal/sesión del desafío, vigente e intentos disponibles; código nunca en URL. |
| `POST /api/v2/me/phone/revoke` | Desvinculación → capacidades actualizadas | Propietario con reautenticación; corta chat. |
| `POST /api/v2/me/exports`, `POST /api/v2/me/deletion-requests` | Solicitud → trabajo y estado | Propietario, comprobación reciente de titularidad y P8; no requieren chat/SMS. |
Callbacks de edad/SMS son interfaces servidor a servidor autenticadas, con audiencia/nonce, firma, vencimiento y dedupe. No existe un endpoint público para establecer `verified`, `adult` o `guardian`. Los códigos fallidos tienen contador atómico; reenviar no reinicia la cuota acumulada. Un SMS entregado no cambia por sí mismo la elegibilidad.
### Catálogo, salas, invitaciones y medios
| Método y ruta | Solicitud / datos | Acceso y precondición |
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `GET /api/v2/catalog` | Fichas aptas y paginadas | Público seguro; una proyección personalizada no entra en caché público. |
| `GET /api/v2/games/{gameId}/versions/{version}` | Distribución pública, digests, estado de publicación | Política de contenido; retirar no convierte en secreto lo ya publicado. |
| `GET /api/v2/rooms?scope=public\|mine` | Resumen paginado | `public` seguro; `mine` exige sesión. Sin chat, tokens, lista infantil o estado privado. |
| `POST /api/v2/rooms` | `game: { id, version, packageDigest, dependencyLockDigest }`, capacidad, visibilidad, nombre, configuración | Juego; fija la selección exacta o rechaza `PACKAGE_NOT_CREATABLE`. |
| `GET /api/v2/rooms/{roomId}` | `RoomView` | Solo miembros/participantes históricos autorizados, también en salas públicas. |
| `POST /api/v2/rooms/{roomId}/memberships` | `invitationToken?` → membresía | Juego; no necesita revisión leída para ocupar plaza: validación atómica de capacidad/token/estado/protección. |
| `POST /api/v2/invitations/resolve` | Token → ficha segura mínima y `roomId` | Lectura sin consumo, CSRF y límites; no usa Idempotency-Key. Token ausente/inválido sin detalle. |
| `GET /api/v2/rooms/{roomId}/invitations` | Metadatos/revisiones, nunca tokens | Anfitrión autorizado. |
| `POST /api/v2/rooms/{roomId}/invitations` | `expectedRoomRevision`, vencimiento/usos → ID/token | Anfitrión en espera; valida cuotas y política. |
| `POST /api/v2/invitations/{invitationId}/revoke` | `expectedRevision` → recibo | Anfitrión actual; token deja de admitir entradas. |
| `POST /api/v2/invitations/{invitationId}/rotate` | `expectedRevision` → nuevo secreto | Anfitrión actual; revoca anterior en la misma transacción. |
| `POST /api/v2/me/media` | Imagen acotada → `mediaId`, estado de validación | Cuenta elegible; menores/edad desconocida solo presets. |
| `GET /api/v2/me/media/{mediaId}` | Estado propio `pending\|ready\|rejected` | Propietario; solo `ready` puede usarse como avatar. |
La URL compartida propone `/join#invite=TOKEN`: el fragmento no se envía en la solicitud HTTP. El cliente lo lleva a memoria, limpia la barra mediante reemplazo del historial y lo resuelve por POST; la pantalla usa `Referrer-Policy: no-referrer`, `Cache-Control: no-store` y no carga terceros. El usuario debe reabrir su enlace si pierde el contexto antes de entrar. No se registran cuerpos con tokens.
Gestión de invitación tiene `invitationRevision`; no aumenta `roomRevision` salvo que cambie también el estado/configuración de sala. La comprobación de anfitrión se hace bajo bloqueo de sala, aunque la precondición sea sobre invitación.
### Chat, avisos y seguridad
| Método y ruta | Datos | Acceso |
| ------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
| `GET /api/v2/rooms/{roomId}/messages` | Página histórica, `historyEpoch`, cursor | Chat; se aplica corte por retirada y redacciones actuales. |
| `GET /api/v2/me/matches` | Historial/resultados proyectados | Propietario; no copia resultado privado del motor. |
| `GET /api/v2/me/notifications` | Página, `inboxRevision`, no leídos | Propietario; sin texto de chat ni datos ocultos. |
| `POST /api/v2/me/notifications/{notificationId}/read` | Lectura monotónica → recibo | Propietario; incrementa bandeja solo si cambia. |
| `POST /api/v2/me/blocks`, `POST /api/v2/me/blocks/{blockId}/revoke` | Destinatario autorizado / desbloqueo | Seguridad, sesión; no revela quién bloqueó al solicitante. |
| `POST /api/v2/safety/reports` | Recurso/persona, categoría, nota opcional → referencia | Seguridad; sin requisito de chat/SMS; contexto de reporte para no autenticados con límites. |
| `GET /api/v2/me/reports/{reportId}` | Estado propio y respuesta mínima | Reportante, con verificación de titularidad. |
| `GET /api/v2/commands/{commandId}` | Recibo propio `found\|unknown` | Sesión; puede consultar tras salir o perder permiso de chat, sin recibir contenido. |
La consola de moderación y los derechos de acceso a evidencia son una interfaz administrativa separada, con roles y objetivos definidos en políticas y operaciones en la sección 9. Debe implementarse antes de comunicación pública. Los reportes sin cuenta no se exponen por ID adivinable: requieren el contexto de reporte emitido, sin revelar datos a terceros.
### Paginación y caché
Listas por cursor opaco ligado a principal, filtros, orden y versión de política; por defecto 20 y máximo 50 elementos. Orden total por fecha de creación + UUID; navegación histórica de chat por `createdSequence`. No offsets ilimitados. Cursor vencido, contexto cambiado o historial redactado da `CURSOR_EXPIRED` y obliga a reiniciar la lista. Un cursor no es autorización.
Las listas móviles como salas públicas no prometen un snapshot transaccional entre páginas: se pueden actualizar entre lecturas; cliente deduplica IDs y refresca la primera página. Chat usa el contrato específico de la sección 6. Las respuestas privadas llevan `private, no-store`; catálogo público puede usar ETag según idioma/proyección segura y assets públicos inmutables por digest. La concurrencia se controla con revisiones de dominio explícitas, no con un ETag que mezcle proyecciones de usuarios.
## 4. Conexión y sobres Socket.IO
Eventos en namespace principal: `platform:message` y `game:message`. El segundo mantiene `protocolVersion: 1` y su propio sync. Los acuses de plataforma son mensajes de aplicación; el callback de Socket.IO no reemplaza un recibo persistido.
Antes de suscribirse se envía un mensaje de negociación independiente:
```json
{
"type": "platform.hello",
"requestId": "bdf2ea3d-1473-4c75-a0f6-b2e56807bc99",
"supportedVersions": [1]
}
```
Respuesta `platform.welcome`: `requestId`, `selectedVersion: 1`, `connectionId` UUID, `serverTime`, límites y `safetyRevision` propia. Sin versión compatible: `platform.negotiation-error` con código y versiones admitidas, sin datos privados, y cierre. No se interpreta `room.sync` para negociar chat o avisos.
Después todos los sobres llevan `platformProtocolVersion: 1` y `type`. Peticiones de lectura llevan `requestId`; mutaciones, `commandId` e `issuedAt`. Flujos llevan `subscriptionId` y recurso. Identificadores de suscripción son propios de conexión/principal y se invalidan al desconectar, cambiar identidad o revocar acceso.
| Mensaje | Dirección | Campos específicos |
| ------------------------- | --------- | --------------------------------------------------------------------------------------------------------------- |
| `room.sync` | C → S | `requestId, roomId`. Sustituye suscripción anterior de esa sala/conexión. |
| `room.snapshot` | S → C | `roomId, subscriptionId, revision, delivery, observedAt, room`. |
| `room.command` | C → S | `roomId, commandId, issuedAt, expectedRevision, expectedMatch, action`. |
| `room.ack` | S → C | `roomId, commandId, duplicate, receipt`. |
| `room.cursor` | S → C | `roomId, subscriptionId, revision`. |
| `room.unsubscribe` | C → S | `requestId, roomId, subscriptionId`. Solo suscripción propia. |
| `platform.unsubscribed` | S → C | `requestId, subscriptionId`. Baja idempotente, sin alterar membresía. |
| `platform.command-status` | C → S | `requestId, commandId`. Sirve también para chat y mutaciones HTTP. |
| `platform.command-result` | S → C | `requestId, commandId, status: found\|unknown`, `receipt` si encontrado. |
| `chat.sync` | C → S | `requestId, roomId`. Solo miembros actuales elegibles para chat. |
| `chat.snapshot` | S → C | `roomId, subscriptionId, requestId, sequence, historyEpoch, messages, historyCursor`. Cola reciente autorizada. |
| `chat.send` | C → S | `roomId, commandId, issuedAt, text`. Autor derivado del servidor. |
| `chat.ack` | S → C | `roomId, commandId, duplicate, receipt`. |
| `chat.event` | S → C | `roomId, subscriptionId, eventId, fromSequence, sequence, historyEpoch, event`. |
| `chat.cursor` | S → C | `roomId, subscriptionId, sequence, historyEpoch`. |
| `chat.unsubscribe` | C → S | `requestId, roomId, subscriptionId`; respuesta `platform.unsubscribed`. |
| `presence.update` | S → C | `roomId, subscriptionId` de sala, `presenceSequence, observedAt, expiresAt, members`. |
| `lobby.changed` | S → C | Señal sin IDs ni actividad de salas privadas; solo cambios del listado público. |
| `notification.changed` | S → C | `inboxRevision` propia; se consulta por HTTP. |
| `platform.access-changed` | S → C | `eventId`, `safetyRevision` propia, recursos/suscripciones invalidados y capacidades propias mínimas. |
| `platform.error` | S → C | `code, messageKey, recovery, requestId? , commandId?, roomId?, subscriptionId?, retryAfterMs?`. |
El servidor no comparte suscripciones personales entre usuarios. Las invalidaciones de seguridad no dependen de recibir otro movimiento: la revocación corta directamente la entrega en servidor. Si el cliente pierde el aviso, el siguiente intento sigue rechazado.
`platform.access-changed` se deduplica por `eventId`, no solo por `safetyRevision`: perder membresía puede invalidar una suscripción sin cambiar la edad/teléfono de la cuenta. Toda invalidación de la suscripción actual se aplica aunque esa revisión de protección siga igual.
## 5. Sala, acciones y sincronización
`RoomView` contiene nombre/visibilidad aptos para destinatario, estado, capacidad, anfitrión, miembros visibles, configuración pública, paquete fijado, política/plazos, capacidades propias y `matchRef` nulo o con `matchId`, tupla del motor y `minimumRevision`. Miembros: `membershipId`, alias/avatar proyectados, estado, listo, plaza provisional y `seatId` nulo hasta inicio. No incluye token de invitación, historial de chat, teléfono/edad ni estado interno del motor. Crear fija `admissionMode: individual|adult|protected` y `communicationMode: off|adult-verified`: solo `adult` admite el segundo; `individual` tiene capacidad 1 y `protected` siempre es privado. Los valores se validan contra políticas y paquete, no conceden permisos por sí mismos ni revelan la edad de un participante.
Las capacidades son indicaciones evaluadas en `observedAt`, pueden caducar por tiempo/permiso y siempre se revalidan. No todo cambio de permiso incrementa `roomRevision`: una invalidación de acceso exige nuevo sync aunque esa revisión sea igual.
```json
{
"platformProtocolVersion": 1,
"type": "room.command",
"roomId": "1093543c-d383-437c-a427-251d39d63e42",
"commandId": "04de01b1-771f-4775-9db0-3137399c464b",
"issuedAt": "2026-10-05T16:00:00Z",
"expectedRevision": 5,
"expectedMatch": null,
"action": { "type": "set-ready", "ready": true }
}
```
| Acción | Payload y permiso |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `set-ready` | `ready: boolean`; propio miembro en espera. Inicio automático según política. |
| `configure` | Capacidad/configuración permitida; anfitrión en espera. Invalida todos los listo; juego/digest no cambian. |
| `choose-color` | `colorId` ofrecido por la política de preparación del juego; propio miembro en espera. El adaptador valida la configuración de inicio; no infiere posibilidades por nombre de juego. |
| `set-invite-message` | Texto limitado y aprobado por política de contenido; anfitrión en espera. No se muestra libremente a menores. |
| `transfer-host` | `targetMembershipId` activo; anfitrión en espera, con efectos P2. |
| `cancel` | Anfitrión en espera; cancelación explícita. |
| `leave` | Miembro; en espera sale, en partida retira mediante motor. El anfitrión responsable de una partida protegida activa provoca cancelación de seguridad sin resultado competitivo según P7. |
| `close-inactive` | Participante activo elegible según política y flujo del motor; cancela sin resultado competitivo. |
En espera `expectedMatch = null`. En retirada/cierre activo es obligatorio `{ matchId, revision }` exacto. Revisión de sala correcta con partida obsoleta da `STALE_MATCH_REVISION`. El servicio bloquea sala y partida y confirma el enlace al motor y su recibo en una sola transacción. El controlador no llama al protocolo de red `game.command` contra sí mismo ni duplica la retirada en dos registros independientes.
Barrera de sync: instalar recepción y buffer acotado, leer revisión R/proyección confirmada, emitir snapshot con `delivery: { kind: "sync", requestId }`, descartar buffer ≤R y publicar R+1… con `delivery: { kind: "live", fromRevision }`. Duplicados se descartan; huecos o overflow exigen nuevo sync. Un snapshot completo leído en R+k no se etiqueta como R+1.
La respuesta de salida puede preceder a la revocación de suscripción o viceversa; el recibo se puede consultar por identidad. Un cliente conserva cada flujo separado. Si llega final de sala con `minimumRevision = 13` y juego sigue en 12, solicita `game.sync` y espera la vista autorizada de al menos 13. Una instantánea de juego más reciente que sala tampoco implica corrupción.
`minimumRevision` registra la última revisión de partida coordinada con un cambio de sala, empezando en 0; no promete ser la última jugada existente ni obliga a aumentar la revisión de sala en cada jugada.
## 6. Chat, redacción y recuperación
`ChatMessage`: `messageId, authorMembershipId, authorDisplay, createdAt, createdSequence, status: visible|redacted, text`. En lápida `text = null`; no hay copia del original en payload público. Normalización NFC, texto no vacío de hasta 500 puntos de código y 2 KiB UTF-8, sin HTML ni adjuntos. Longitudes se verifican tras normalizar y la huella idempotente usa el texto validado. Cuerpos no conformes se rechazan, no se truncan.
`sequence` ordena **eventos**, no solo mensajes. `message.created` crea un mensaje; `message.redacted` apunta a su ID, añade secuencia nueva y aumenta `historyEpoch`. No se reemite texto anterior a la redacción en un replay. Un bloqueo personal genera invalidación de acceso y nuevos marcadores sin contenido donde proceda; no cambia el mensaje global ni revela el autor filtrado.
Cada `chat.sync` empieza de nuevo con los últimos 50 mensajes como máximo, limitados además por bytes. Se instala primero la suscripción, se lee snapshot R y se liberan después eventos >R. La navegación de mensajes anteriores se hace por HTTP con `historyCursor`; no retiene una conexión entera mientras pagina todo el historial. La reconexión muestra esa cola reciente, sin repetir sonidos; no promete reproducción exhaustiva de todos los eventos desconectados.
Un evento vivo tiene `sequence = fromSequence + 1`. Hueco, cursor adelantado o epoch superior provoca nuevo sync y descarta el historial cacheado. Una página HTTP incluye `historyEpoch`; el cliente no mezcla una página de epoch anterior al ya observado. En servidor se revalida permiso/epoch antes de publicar una página o outbox. Así una respuesta retrasada no restaura texto que ya fue moderado.
La membresía almacena `chatJoinedAfterSequence` al entrar y, al retirarse, `chatLeftAtSequence`. Solo mensajes con `createdSequence` posterior al primero y no superior al segundo son legibles; el segundo es nulo mientras continúa. Se sincronizan únicamente eventos que puedan proyectarse dentro de ese intervalo, sin revelar autores/texto fuera de él. El antiguo miembro retirado consulta ese historial por HTTP si conserva elegibilidad adulto/SMS y ve redacciones posteriores; no tiene suscripción viva. Al perder elegibilidad tampoco conserva historial. La paginación aplica políticas actuales, no las de cuando se creó el cursor.
Las salas protegidas/mixtas rechazan `chat.sync`, envío e historial incluso para adultos con SMS. El mensaje se purga a los 30 días desde creación; el borrado aumenta `historyEpoch` e invalida cursores/cachés igual que una redacción. No se conserva su texto en outbox/replay fuera del período; el caso de moderación, si existe, tiene almacenamiento y permisos distintos. Un mensaje purgado puede conservar un marcador mínimo mientras el flujo siga existiendo, sin texto ni datos del autor.
## 7. Límites, avisos y recuperación
Valores iniciales para conformidad, anunciados en `bootstrap/welcome`:
| Límite | Valor propuesto |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| JSON de entrada HTTP / plataforma socket | 64 KiB / 16 KiB; profundidad 32. |
| Salida de plataforma / página | 256 KiB; reducir elementos y devolver cursor si se alcanza. |
| Sobre de juego | 64 KiB según protocolo del motor; se valida por separado. |
| Suscripciones por conexión | Hasta 5 salas y 5 chats; total máximo 10. |
| Buffer por suscripción al sincronizar | 256 eventos o 1 MiB, lo que ocurra antes. |
| Cursores sala/chat | Cada 15 s; detectar incluso la última entrega perdida. |
| Presencia | Latido observado cada 15 s, TTL 45 s, secuencia por suscripción. |
| Consulta de avisos mientras hay foco | Al conectar, recuperar foco y cada 30 s; `inboxRevision` cambia también al leer. |
| Chat | 15 envíos/10 s por principal y 100/min por sala como valores iniciales, además de límites globales. |
| Avatar | Hasta 1 MiB de entrada y 1024×1024 píxeles decodificados; PNG/JPEG/WebP estáticos, nueva codificación antes de publicar. |
Configurar el límite agregado de Socket.IO no sustituye los límites específicos de mensaje. El servidor rechaza entradas demasiado grandes antes de ejecutar y corta clientes lentos sin crecimiento ilimitado. Una presencia caducada pasa a desconocida, no provoca derrota. Lecturas periódicas se suspenden en segundo plano; al volver se resincroniza.
Al reconectar: obtener sesión/capacidades actuales, negociar plataforma, sincronizar salas permitidas, solo chats elegibles y partidas asociadas; consultar recibos inciertos y reintentar el mismo sobre. Las proyecciones privadas se aíslan por identidad y se eliminan al cambiarla. No se restaura acceso por el `socket.id` antiguo ni por la recuperación automática de Socket.IO.
## 8. Errores y compatibilidad
HTTP usa [Problem Details, RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html): `type` URI estable, `title`, `status`, `code`, `requestId`, `recovery` y campos permitidos. Las URIs de problema deben publicarse con su documentación antes de lanzar; no se afirma que ya existan. No hay mensajes de excepción, payloads privados ni identificadores de cuentas ajenas en errores.
| HTTP | Códigos y uso | Recuperación |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| 400 / 413 | `INVALID_REQUEST`, `PAYLOAD_TOO_LARGE` | Corregir intención; no reintento automático. |
| 401 | `SESSION_REQUIRED`, `SESSION_EXPIRED` | Reautenticar y limpiar proyecciones. |
| 403 | `TERMS_REQUIRED`, `CHAT_NOT_ELIGIBLE`, `ACCOUNT_RESTRICTED`, `FORBIDDEN` | Mostrar requisitos propios o retirar acceso. |
| 404 | `RESOURCE_NOT_FOUND` | También para recursos privados cuya existencia no se autoriza. |
| 409 | `STALE_ROOM_REVISION`, `STALE_MATCH_REVISION`, `STALE_RESOURCE_REVISION`, `ROOM_FULL`, `ROOM_NOT_WAITING`, `PACKAGE_NOT_CREATABLE` | Sincronizar y revisión humana de una nueva intención. |
| 409 | `COMMAND_ID_REUSED`, `COMMAND_EXPIRED`, `CURSOR_EXPIRED`, `TERMS_VERSION_CHANGED` | No cambiar UUID en automático; reiniciar consulta/flujo pertinente. |
| 404 / 409 | `INVITATION_UNAVAILABLE` para resolver token; `INVITATION_CONFLICT` para propietario con revisión obsoleta | Mensaje genérico al visitante; refrescar al propietario. |
| 429 / 503 | `RATE_LIMITED`, `TEMPORARY_UNAVAILABLE` | `Retry-After` HTTP / `retryAfterMs` socket; mismo comando si sigue admisible. |
La falta de revisión obligatoria es `400 INVALID_REQUEST` en este diseño, porque las precondiciones son campos del dominio. No se usa **428 para aceptar condiciones**: RFC 6585 lo define para exigir una petición condicional ([sección 3](https://www.rfc-editor.org/rfc/rfc6585.html#section-3)). Si en el futuro se añade `If-Match`, deben definirse aparte 428 por ausencia y 412 por condición no satisfecha.
Socket tiene las mismas categorías semánticas, sin fingir un estado HTTP. Rechazos definitivos de sala/chat llegan en recibo; problemas previos al procesamiento o transitorios en `platform.error`. `CHAT_NOT_ELIGIBLE` no detalla el estado de otros usuarios. Antes de negociación solo se permite `platform.negotiation-error`.
```json
{
"type": "urn:juegoland:problem:room-full",
"title": "La sala está completa",
"status": 409,
"code": "ROOM_FULL",
"requestId": "f8aa2fa1-aec8-41c8-a27b-644e999df03a",
"recovery": "refresh-room"
}
```
Cambiar significado, tipo obligatorio o regla de autorización requiere revisar compatibilidad; un campo opcional de respuesta no autoriza campos nuevos de solicitud. Versiones de cliente incompatibles deben actualizarse antes de comunicar o ver un juego, incluida la API antigua. La fase siguiente produce OpenAPI, esquemas discriminados y pruebas desde una fuente común, con ejemplos de éxito, errores y carreras; este documento no declara que esos artefactos ya existan.
## 9. Flujos derivados de las políticas de producto
Operaciones de diseño que deben incorporarse a OpenAPI y pruebas. Los métodos/rutas siguientes sustituyen la ambigüedad del primer borrador; no se afirma que existan endpoints funcionando. Mutaciones de dominio mantienen recibos comunes; autenticación y tokens de credencial usan intentos específicos, vinculados al contexto anónimo/sesión y con respuesta secreta recuperable acotada según P6. No se incluye contraseña o token en una huella persistida de dominio.
### Cuenta y recuperación
| Operación | Contrato mínimo |
| ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /api/v2/auth/register` | `authAttemptId`, correo, contraseña, versión de condiciones; crea cuenta pendiente o convierte invitado propio sin cambiar `userId`. Declarar mayoría no la acredita. El correo de respuesta no enumera cuentas existentes. |
| `POST /api/v2/auth/email-verifications` | Solicitar envío/reenvío para contexto propio; 202 genérico, cuota y tarea deduplicada. |
| `POST /api/v2/auth/email-verifications/complete` | Token del enlace + contexto; consumir atómicamente, verificar correo sin elevar edad/tutela/SMS. |
| `POST /api/v2/auth/login` | `authAttemptId`, correo, contraseña, `rememberMe`; servidor decide si admite sesión larga. Credencial inválida con respuesta uniforme, sin distinguir correo inexistente. |
| `POST /api/v2/auth/reauthenticate` | Prueba de credencial y segundo factor cuando corresponda; resultado ligado a sesión/finalidad, válido 5 min; no recibe `reauthenticated: true` del cliente. |
| `POST /api/v2/auth/recovery-requests` | Correo y contexto; 202 genérico, sin iniciar sesión ni revelar estado. |
| `POST /api/v2/auth/recoveries/complete` | Intento, token y nueva contraseña; consumo de un uso + credencial + revocación de sesiones/tokens + suspensión de privilegios sensibles en una transacción. Exige después login. |
| `POST /api/v2/me/password` | Reautenticación, nueva contraseña; revocar otras sesiones y rotar la propia de forma atómica. No confundir cambio autenticado con recuperación por canal perdido. |
| `POST /api/v2/me/email-changes`, `POST /api/v2/me/email-changes/{changeId}/complete` | Reautenticación, nuevo correo / token; unicidad y verificación antes de cambiar, aviso al anterior, sin transferir cuenta por correo escrito. |
| `GET /api/v2/me/exports/{exportId}` | Estado propio; la descarga se emite solo tras comprobación vigente de titularidad, un uso, 24 h y sin URL/token en logs. |
| `GET /api/v2/me/deletion-requests/{requestId}` | Estado propio y excepciones pertinentes; tras revocación, acceso con credencial de seguimiento específica, sin reactivar sesión social. |
El contrato infantil de creación de credencial y emparejamiento requiere un esquema aparte; no se reutiliza el PIN actual. El desafío de emparejamiento se emite por responsable con vínculo activo, propósito/principal/dispositivo específicos, caduca en 5 min, admite 5 intentos y se consume una vez. Ningún dato de tutor o menor ajeno se revela antes de autorización. MFA administrativo es requisito de activación: endpoints WebAuthn/challenges, recuperación y validación de origen/RP deben especificarse antes de abrir la consola, no simularse con SMS.
### Tutela, contactos y capacidades
| Operación | Contrato mínimo |
| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /api/v2/me/supervision-requests` | Responsable adulto acreditado y reautenticado; referencia de proceso de evidencia y alcance. No enlaza un menor existente solo por correo/nick. |
| `GET /api/v2/me/supervisions` | Vínculos propios y estado mínimo; sin consulta arbitraria por `childUserId`. |
| `POST /api/v2/me/supervisions/{supervisionId}/children` | Vínculo de alta validado; crear cuenta tutelada, preset y políticas. La evidencia, edad exacta y credenciales no se publican en perfil. |
| `POST /api/v2/me/supervisions/{supervisionId}/revoke` | Reautenticación, revisión del vínculo, motivo privado acotado; incrementar protección y revocar contactos/permisos dependientes. |
| `POST /api/v2/me/supervisions/{supervisionId}/contact-authorizations` | Referencia de principal autorizado mediante intercambio privado, alcance y caducidad; no directorio público de menores. Para dos menores exige aprobación de ambos responsables. |
| `POST /api/v2/me/contact-authorizations/{authorizationId}/revoke` | Titular responsable, revisión; corta nuevas admisiones y aplica cancelación de seguridad si comparten partida protegida activa. |
| `POST /api/v2/me/supervision-disputes`, `POST /api/v2/me/supervision-release-requests` | Ayuda/corrección o emancipación; verificación adecuada y revisión, sin transferencia automática de control ni chat. |
Evidencia y callbacks de tutela/edad son interfaces internas del adaptador elegido; no aceptan afirmaciones de estado del navegador. La revocación produce `platform.access-changed`, invalida suscripciones y vuelve a proyectar vistas propias; a los demás solo indisponibilidad genérica. La revisión del vínculo no sustituye `safetyRevision` ni viceversa. En una sala protegida la cancelación por seguridad usa el núcleo con su revisión actual, no una revisión aportada por el responsable sobre una partida a la que quizá no tiene acceso.
### Moderación y publicación interna
| Operación | Contrato mínimo |
| --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/v2/me/restrictions` | Motivo notificable, alcance/vigencia y posibilidad de recurso; sin identidad del reportante ni reglas internas de detección. |
| `POST /api/v2/me/appeals`, `GET /api/v2/me/appeals/{appealId}` | Restricción/caso propio, nota acotada, estado y decisión; identidad verificada aunque esté suspendida, sin exigir chat/SMS. |
| `GET /internal/v2/moderation/cases`, `GET /internal/v2/moderation/cases/{caseId}` | MFA, rol, asignación y acceso auditado; paginación acotada, sin exportación masiva de evidencia por defecto. |
| `POST /internal/v2/moderation/cases/{caseId}/decisions` | `expectedRevision`, acción, motivo, alcance y duración; recibo idempotente + restricción + auditoría + invalidaciones confirmados juntos. |
| `POST /internal/v2/moderation/cases/{caseId}/reviews` | Revisión por otra persona, precondición de caso y resolución auditable; no sobreescribe la decisión previa. |
Las interfaces internas no se hacen públicas por compartir un binario Go: ruta/red/roles separados y ningún acceso con cookie de jugador. Publicación de juegos se realiza solo por pipeline/consola interna del equipo con permiso de publicación; no existe `POST /api/v2/games` para usuarios. El identificador de build/artefacto debe corresponder a paquete validado y reglas disponibles, no a una URL ejecutable elegida por el solicitante.

@ -0,0 +1,152 @@
# Arquitectura de tres capas y evaluación de Go — Juegoland v2
**Estado:** propuesta técnica del 5 de octubre de 2026. El usuario fija tres capas —API, servidor y frontend— y sitúa la API en Go; solicita valorar también Go para servidor. La recomendación es Go para ambas capas de backend en un monolito modular, con frontend Svelte/TypeScript. La elección de Go para el servidor y el nuevo transporte se documentan como recomendación, no como migración ya aprobada o ejecutada.
El [modelo de plataforma](../platform-spec-v2.md), sus [políticas](product-policies.md) y la [API](api-and-events.md) definen comportamiento independiente del lenguaje. El [SDK actual de diseño](../game-engine/contracts.ts) sí tiene firmas TypeScript y el formato v1 describe entradas de módulos: necesita una adaptación explícita para reglas nativas Go. La producción sigue siendo Node/Socket.IO/PostgreSQL.
## 1. Las tres capas
| Capa | Responsabilidad | Tecnología propuesta | Límite |
| -------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| Frontend | Pantallas, interacción, accesibilidad, vistas autorizadas, animación/audio y recuperación de conexión. | Svelte/TypeScript; HTML/CSS y recursos propios. | No conoce secretos, no decide edad/permiso, resultado o azar; no accede a DB. |
| API | HTTP/tiempo real, negociación, decodificación y límites, sesión/CSRF/origen, correlación y traducción de errores. | Go. | No implementa reglas de juego ni duplica políticas en cada controlador. |
| Servidor | Casos de uso, autorización de dominio, cuentas, salas, protección, motor, transacciones, plazos, moderación y publicación. | Go recomendado. | Es la única autoridad que confirma cambios. No depende de componentes UI ni llama a su propia API por HTTP. |
API y servidor son separaciones de código/responsabilidad. En la primera topología se compilan y despliegan juntos; se llaman mediante funciones e interfaces Go. No hacen falta dos servicios de red ni bases de datos distintas para conservar tres capas. PostgreSQL, correo/SMS, almacenamiento de recursos y métricas son infraestructura de soporte.
```mermaid
flowchart LR
USER[Navegador] --> PROXY[Proxy HTTPS]
PROXY --> FE[Frontend Svelte]
PROXY --> API[API Go: HTTP y tiempo real]
subgraph BACKEND[Un backend modular Go]
API --> APP[Casos de uso y autorización]
APP --> DOMAIN[Identidad, salas y protección]
APP --> ENGINE[Núcleo y reglas propias]
JOBS[Plazos y tareas] --> APP
end
APP --> DB[(PostgreSQL)]
DB --> OUT[Outbox y entregas autorizadas]
OUT --> API
APP --> PROVIDERS[Adaptadores de email, edad y SMS]
FE --> ASSETS[Recursos públicos por digest]
```
El frontend puede servirse estático si se adopta una compilación compatible, o conservar SSR de SvelteKit con su proceso Node. Usar Go en el backend no elimina por sí solo Node del build o del SSR. Esta decisión de entrega se toma después de inventariar rutas/SSR, SEO y autenticación; no se exige rehacer Svelte.
## 2. Por qué Go encaja y qué no resuelve
Go proporciona compilación nativa y mecanismos de concurrencia adecuados para servicios de red; su documentación describe estos objetivos en la [FAQ oficial](https://go.dev/doc/faq). Para este producto, las ventajas esperadas son un backend tipado, distribución del ejecutable, herramientas de análisis/prueba y control explícito de cancelaciones, recursos y errores. Son razones de ingeniería, no una medición de que vaya a superar al servicio actual.
Los juegos por turnos no justifican por sí solos una reescritura por rendimiento. Persistencia, proyecciones por usuario, fanout, bloqueos y recuperación pueden dominar la latencia. Hay que medir el perfil de carga de las políticas en hardware documentado. Goroutines no sustituyen límites de concurrencia, colas acotadas ni bloqueos de base de datos.
Go tampoco resuelve errores del proxy, rutas/NAT, políticas incompletas, autorización o pérdida de mensajes. Mantener las invariantes importa más que el lenguaje. La API en Go será útil si centraliza entrega y llama a una autoridad de dominio clara; añadirla solo como segundo CRUD que escribe sobre los mismos datos que Node introduciría carreras.
## 3. Alternativas comparadas
| Alternativa | Ventaja | Coste/riesgo | Uso recomendado |
| -------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| API y servidor Node | Menor trabajo inicial, reutiliza reglas y SDK TS. | No satisface la dirección de API Go elegida. | Línea base para comparar resultados y mantener producción durante el cambio. |
| API Go → servidor Node autoritativo | Conserva motor y una sola transacción en Node. | Dos procesos, salto de red, identidad interna, despliegue y trazas duplicados. | Puente temporal si necesitamos API Go antes de portar el motor. |
| API y servidor Go, reglas Go incluidas | Una autoridad, misma transacción, un runtime de backend y separación de capas por paquetes. | Portar SDK, reglas, validadores y comprobar equivalencia. | Objetivo recomendado al publicar solo juegos propios. |
| Go + evaluador JS/WASM | Podría conservar módulos de reglas o abrir varios runtimes. | ABI, límites, compatibilidad JS, aislamiento, determinismo y coordinación añadidos. | Fuera del primer alcance; exige una necesidad y un prototipo específico. |
No se incluye un intérprete JS o un sistema de plugins dinámicos solo para simular que el SDK TS funciona en Go. Tampoco se cargan `.go` de un paquete en ejecución. Los juegos propios permiten registrar implementaciones compiladas y versionadas junto al servidor. Publicar nuevos gráficos/metadatos y publicar nuevas reglas tienen ciclos distintos.
## 4. Autoridad y transacciones
Una operación autorizada entra por un caso de uso común, tanto desde HTTP/tiempo real como desde tareas internas:
1. Validar estructura, identidad, presupuesto técnico y contexto de autorización.
2. Abrir la unidad de trabajo. Serializar autorización/revocaciones aplicables y bloquear agregados en orden global: contexto de permisos, sala, partida, registros dependientes. Toda ruta y tarea usa el mismo orden; relaciones de varias personas se ordenan por ID y se revalidan bajo bloqueo.
3. Buscar recibo propio y comprobar idempotencia; si el comando es nuevo, comprobar permisos, revisiones, admisión y versión instalada.
4. Ejecutar reglas locales, puras y acotadas con servicios deterministas. Validar transición y proyecciones.
5. Confirmar sala, partida, resultado, tareas, recibo y outbox en **una transacción PostgreSQL**.
6. Fuera de la transacción, entregar acuse/publicaciones con permisos actuales y ejecutar efectos externos idempotentes.
La documentación de [transacciones Go](https://go.dev/doc/database/execute-transactions) distingue operaciones sobre `sql.Tx` de operaciones sobre el pool: mezclarlas puede ejecutar escrituras fuera de la transacción. Los repositorios participantes reciben la misma unidad de trabajo; no hacen commit interno ni abren otra conexión para «guardar el resultado».
Sin llamadas a SMS, verificador, correo, HTTP interno ni espera de UI dentro de un bloqueo. Los efectos externos usan tarea/outbox y conciliación. Un timeout no demuestra que un commit falló: se consulta/reintenta el mismo comando y se recupera su recibo. No se promete exactamente una entrega de red; se evita repetir el efecto de dominio.
La revocación debe cerrar entregas también si no llega otro comando: invalidación directa, reautorización de outbox e historial y purga de cachés controladas. Un snapshot con permisos antiguos no se publica por haber sido calculado antes de la revocación.
## 5. Qué se conserva del protocolo y qué requiere adaptación
| Contrato actual | Tratamiento con Go |
| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| IDs, sobres JSON, revisiones, recibos, estados, vistas por destinatario | Conservar semántica y fixtures. No cambiar la versión de red solo por cambiar de lenguaje. |
| Manifiesto, recursos, barajas/dados/tableros, presentación y animaciones | Conservar separación entre reglas privadas y recursos/UI públicos. El frontend sigue TS. |
| `setup`, acciones, validación, `availableActions`, `project`, `projectEvent`, `onLifecycle` | Diseñar interfaces Go equivalentes; las firmas TS son referencia semántica, no código invocable desde Go. |
| `rules.entry` y compilación TS descrita por formato v1 | Añadir un perfil de ejecución y distribución versionado antes de publicar paquetes Go. El esquema v1 no se modifica de forma incompatible manteniendo la misma versión. |
| Zod y sus refinamientos | Zod puede seguir como herramienta de autoría/build. Go necesita validación estructural y semántica equivalente; generar tipos no reproduce refinamientos entre campos. |
| Lock/digests y reglas retenidas | Relacionar paquete y versión con implementación compilada y build; conservar todas las versiones referenciadas por partidas activas. |
El futuro perfil nativo tendrá un identificador de implementación registrado por el build, no una ruta suministrada por un jugador. Debe especificar `runtime`, versión de ABI/SDK, compatibilidad de estado y prueba de que el build contiene exactamente la implementación asociada al digest. Sus nombres/campos definitivos pertenecen a la siguiente versión del esquema de paquete, no se introducen como campos libres en v1.
No recompilar un módulo con cambios de reglas bajo la misma versión. Una build nueva puede contener varias implementaciones antiguas; readiness impide servir partidas cuyo módulo no esté disponible. El rollback conserva el conjunto completo necesario o detiene nuevas admisiones, nunca sustituye reglas sobre una partida existente.
La equivalencia entre runtimes exige vectores compartidos de estado, acciones, errores, proyecciones, resultados y eventos. En azar: mismo algoritmo/versionado y estado, consumo exacto de tiradas, rechazo de sesgo y reglas de barajado. No sustituir un RNG persistido por el generador estándar de Go. Datos dentro del rango seguro JSON, reglas claras para `null`/ausencia, Unicode, orden canónico, timestamps y mapas; no depender del orden de iteración Go ni de aritmética diferente.
Los juegos actuales que dependen de una librería JS, como ajedrez, necesitan equivalencia probada de reglas/casos extremos y revisión de licencia al portar o sustituir la dependencia. No basta con que las primeras jugadas parezcan iguales. Las vistas de Hundido deben probar además ausencia de filtraciones por jugador, eventos y errores.
## 6. HTTP, tiempo real y compatibilidad de transporte
Mantener HTTP JSON bajo `/api/v2`, contratos explícitos y errores de dominio de la API. La recomendación para el backend Go final es WebSocket estándar con un binding propio pequeño de los sobres ya definidos, evitando depender de una implementación no evaluada de Socket.IO en Go. **La API documentada actualmente usa Socket.IO; la recomendación no cambia ese contrato automáticamente.**
Socket.IO añade su propio protocolo; su documentación confirma que un cliente Socket.IO no se conecta a un servidor WebSocket simple ([fuente oficial](https://socket.io/docs/v4/#what-socketio-is-not)). Antes de adoptar el binding nuevo se debe publicar y probar:
- Ruta y subprotocolo versionados, un sobre por mensaje de texto UTF-8, separación `platform.*`/`game.*` por contrato y límites propios; rechazo de binario y compresión hasta evaluar su necesidad/riesgos.
- Cookie de sesión, `Origin` permitido y autenticación/CSRF antes de suscribir. El navegador no añade cabeceras arbitrarias a WebSocket: usar primer mensaje de autenticación con nonce/CSRF obtenido por HTTP, timeout corto y conexión todavía sin acceso; ningún secreto en query string.
- Negociación, ping/pong, timeouts del proxy, cierre/drenaje, backoff con jitter, reconexión, cursores, límites de buffers y recuperación tras segundo plano móvil.
- Recibos duraderos y barreras iguales a las del protocolo de aplicación. No confundir confirmación de transporte con commit.
- Política explícita cuando no se puede abrir WebSocket: mensaje recuperable y reintento acotado; cualquier fallback HTTP requiere rutas y pruebas de equivalencia. No prometer long-polling si no se implementa.
Durante la transición `/socket.io` sigue con Node para clientes/partidas que lo requieran. El endpoint nuevo negocia de forma distinta; no se redirige el protocolo antiguo al nuevo como si fueran intercambiables. Un adaptador de compatibilidad, si se necesita, traduce transporte hacia la misma autoridad, nunca aplica dos comandos.
## 7. Organización de código propuesta
Estructura orientativa, no directorios ya creados:
```text
cmd/juegoland/ composición y arranque
internal/api/http/ handlers, decodificación, CSRF, errores
internal/api/realtime/ conexión, negociación, suscripciones
internal/application/ casos de uso y unidad de trabajo
internal/domain/identity/ cuentas y sesiones
internal/domain/safety/ edad, teléfono, tutela y permisos
internal/domain/rooms/ salas, miembros e invitaciones
internal/domain/chat/ comunicación y redacción
internal/domain/moderation/ casos, restricciones y recursos
internal/engine/ núcleo determinista y versiones
internal/games/ reglas propias registradas por build
internal/infra/postgres/ repositorios, migraciones y outbox
internal/infra/providers/ email, SMS y verificador
internal/jobs/ tareas que invocan casos de uso
contracts/ esquemas y fixtures compartidos
```
El dominio no importa HTTP, Socket.IO, drivers SQL o SDK de proveedor. API invoca aplicación; infraestructura implementa interfaces requeridas por esta. El punto de composición conecta implementaciones. No crear una jerarquía vacía por cada entidad ni llamar «microservicio» a un paquete.
Se adopta una versión soportada de Go al implementar, fijada en toolchain/CI; driver, router y librería WebSocket se seleccionan con mantenimiento, licencias y compatibilidad comprobados. No se instala ni contrata nada en esta fase de especificación.
## 8. Migración sin doble autoridad
1. Completar contratos de plataforma y fixtures compartidos; inventariar dependencias/estado de los juegos actuales. Mantener producción estable.
2. Prototipo vertical Go con Conecta 4: cuenta sintética, sala, comando, commit, outbox, reconexión y reinicio con PostgreSQL. Probar también revocación concurrente. Comparar con Node; aún sin tráfico real.
3. Probar Hundido para información oculta/acciones independientes y Brisca para azar/mazos. Fijar SDK Go, perfil de paquete y binding antes de migrar el resto.
4. Si se necesita API Go mientras el dominio sigue Node, Go delega la operación completa al dueño Node, sin escritura parcial. Canal interno autenticado, origen no público, identidad verificable y deadlines; Node sigue validando permisos y controla toda la transacción. Una identidad en cabecera sin autenticar no vale.
5. Preferir drenar salas activas antiguas en su propietario y enviar **nuevas** salas por cohortes al backend nuevo. No repartir la escritura de una misma sala entre Go y Node. Identidad y protección mantienen una autoridad común, y una revocación afecta a ambos antes de nuevas entregas.
6. Si un traslado de partida activa fuera imprescindible: detener nuevas escrituras, bloquear y cambiar propietario con generación/fencing duradero, migrar snapshot validado sin repetir `setup`, confirmar antes de enrutar. El servidor anterior rechaza su generación obsoleta. No activar este camino sin pruebas de caída en cada paso.
7. Retirar Node de juego solo cuando no queden partidas/compatibilidades que dependan de él. SSR/build de frontend se evalúan por separado. Rollback mantiene contratos, esquema, versiones y políticas de seguridad; no vuelve a habilitar chat a menores.
No existe una transacción `sql.Tx` compartible entre procesos Go y Node. Tampoco soluciona ese problema «usar la misma base de datos» con commits independientes. Si en un futuro Go calcula mediante un evaluador remoto sin efectos, deberá hacerlo fuera de locks y confirmar por comparación de todas las revisiones/lecturas relevantes; eso es otro diseño y no la topología inicial recomendada.
## 9. Criterios de decisión antes de implementar el cambio completo
- Go conserva equivalencia semántica de reglas, filtros privados, RNG y recibos, y puede instalar/retener versiones de juego.
- Existe un único dueño por agregado, una unidad de trabajo para sala/partida y revocación consistente entre interfaces.
- Fixtures JSON pasan en Go y TS, incluidos casos negativos, refinamientos y canonicalización; ningún generador se trata como prueba de equivalencia.
- Pruebas de integración con PostgreSQL, carreras, proceso detenido tras commit, restart, outbox y cliente lento; carrera de datos Go en pruebas pertinentes.
- Perfil de carga y restauración de las políticas medidos en el despliegue candidato; frontend móvil y accesibilidad verificados.
La recomendación queda, por tanto, en **frontend Svelte/TypeScript + API Go + servidor/motor Go**, con separación lógica entre API y dominio y un backend modular al inicio. Su principal coste es portar y probar el runtime de juegos; los contratos y recursos ya diseñados siguen siendo una inversión aprovechable.

@ -0,0 +1,148 @@
# Protección del menor y comunicación — Juegoland v2
**Estado:** requisito de diseño transversal, revisado el 5 de octubre de 2026. Complementa el [modelo](../platform-spec-v2.md), la [API](api-and-events.md) y las [políticas de producto](product-policies.md), que concretan admisión, supervisión, plazos y operación. No acredita que la protección esté implementada en producción ni certifica cumplimiento legal.
## 1. Decisiones confirmadas
El usuario ha fijado dos condiciones: **chat desactivado para menores** y **teléfono verificado mediante SMS para quien pueda usar el chat**. Se aplican a todo el producto, no solo al botón de enviar.
- Un menor no envía, lee, se suscribe ni recupera historial de chat. Tampoco recibe fragmentos en avisos, exportaciones de sala, audio, accesibilidad, respuestas de errores o API antigua.
- La edad desconocida, caducada o en revisión no acredita mayoría de edad. Se mantiene la restricción de comunicación.
- El SMS acredita control del número en ese momento. No acredita edad, identidad civil, parentesco, unicidad de persona ni ausencia de abuso.
- Un tutor, anfitrión, código de sala o permiso de juego no habilita chat a menores. No hay excepción parental al bloqueo decidido.
- Jugar sin chat no exige aportar un teléfono solo para verificar una función inaccesible. El juego debe ofrecer instrucciones, estados y acciones suficientes sin conversación.
- Una sala privada tampoco está exenta. Como política adicional de diseño, las salas protegidas/mixtas tienen chat desactivado para todos sus participantes; su configuración no cambia al salir un menor. Solo salas de admisión adulta pueden ofrecer chat a adultos elegibles.
El perfil inicial fija umbral de chat de 18 años y puede exigir uno superior según la política aplicable. [P7](product-policies.md#4-edad-supervisión-y-comunicación--p7) desarrolla una propuesta territorial para España, cuentas tuteladas de 6–17 y alta autónoma adulta, con modo individual seguro para edad desconocida. Son decisiones de producto diferenciadas de umbrales legales. Verificador, procedimiento de tutela y validación territorial son dependencias de activación. No se confunde edad mínima para jugar con umbral para comunicar.
## 2. Modelo de autorización
Registros privados independientes:
| Registro | Campos conceptuales | Qué prueba |
| ----------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Comprobación de edad | Estado, umbral acreditado, método, emisor, referencia de evidencia, emisión/caducidad, versión de política | Cumplimiento del umbral requerido; una fecha escrita por el usuario no es prueba suficiente. |
| Teléfono | Número cifrado, índice HMAC para límites, estado, fecha de comprobación, vencimiento/revocación | Control del número; la vista propia muestra solo terminación enmascarada. |
| Restricciones | Capacidades suspendidas, motivo interno, inicio/fin, revisión | Si puede comunicar, jugar, invitar o publicar contenido. |
| Relación de supervisión | Adulto responsable, menor, alcance, prueba, aceptación y revocación | Autorización específica para supervisar; no deriva de compartir teléfono/correo. |
| Consentimientos | Finalidad, versión, quién lo otorgó, fecha y revocación | Una decisión concreta; no reemplaza los registros anteriores. |
`safetyRevision` aumenta con cualquier cambio de estos permisos. La condición efectiva es:
```text
chat.read/send =
sesionValida
AND cuentaHabilitada
AND condicionesVigentes
AND mayoriaDeEdadAcreditadaYVigente
AND telefonoSMSVerificadoYVigente
AND sinRestriccionDeComunicacion
AND salaDeAdmisionAdultaConComunicacionHabilitada
AND accesoALaSalaYAlIntervaloDeMensajes
AND politicaDeBloqueosPermiteEntrega
```
Además, `send` exige sala no terminal y membresía no retirada. La política se evalúa en HTTP, Socket.IO, consultas de historial, recibos que contengan datos, generación de avisos y publicación de outbox. `permissions` del cliente es una ayuda de interfaz; nunca autoridad. Las razones detalladas de edad/teléfono se muestran únicamente al propio usuario; a terceros se les ofrece indisponibilidad genérica.
Un cambio invalidante revoca la suscripción antes de nuevas entregas, emite `platform.access-changed`, vacía colas afectadas y elimina el caché de chat controlado por el cliente. El envío revalida permisos incluso si la publicación se preparó con otra revisión. El cliente nunca persiste chat en almacenamiento offline ni cachés compartidos. Una comprobación caída o una prueba caducada no abre el acceso por defecto.
## 3. Verificación por SMS
Solo la cuenta autenticada, con mayoría de edad ya acreditada y autorización de comunicar salvo teléfono, puede solicitar este flujo. No se envían SMS automáticamente al navegar ni se verifica a menores para intentar desbloquear el chat.
Un invitado permanece en modo de edad desconocida; para iniciar comprobación de edad/teléfono convierte su identidad en cuenta recuperable. La conversión por sí misma no eleva permisos.
1. El usuario solicita verificar un número. El servidor normaliza a E.164, valida destinos permitidos y crea `challengeId` ligado a principal, sesión, finalidad y número. Requiere autenticación reciente al cambiar un número existente.
2. Una tarea de envío idempotente solicita el SMS al proveedor. Sus estados son `queued`, `sent`, `failed`, `expired`, `locked` o `verified`. Un acuse de entrega del proveedor solo confirma entrega, no verificación.
3. El usuario introduce el OTP. El servidor comprueba desafío, vigencia, intentos y código con comparación segura, y consume el desafío una sola vez.
4. Se confirman juntos la verificación del número, el recibo y `safetyRevision`. Después se recalculan capacidades; el cliente vuelve a suscribirse si sigue siendo elegible.
Valores técnicos iniciales propuestos: OTP aleatorio de 6 cifras, vigencia de 5 minutos, hasta 5 intentos por desafío, 60 segundos entre envíos, hasta 3 envíos en 15 minutos por principal y número, además de límites por IP y presupuesto global. Son configurables y deben ensayarse frente a abuso y acceso legítimo. Reenviar crea un desafío nuevo e invalida el anterior sin reiniciar los límites acumulados.
El OTP no se guarda en claro ni con un hash simple susceptible de probar las 10^6 combinaciones: se guarda un MAC con clave de servidor y contexto del desafío, con borrado al vencer/consumir. El teléfono va cifrado; el índice de límites usa HMAC con otra clave, no un hash público enumerable. Logs, métricas y trazas excluyen número completo, OTP y cuerpo de proveedor.
La huella del recibo de verificación tampoco permite probar OTP fuera de línea: usa MAC, no un hash público del cuerpo. Un intento incorrecto confirmado conserva recibo y contador; reintentar el mismo comando no cuenta dos veces, pero corregir el código es otro intento sujeto al límite. Cambiar el código conservando el UUID se rechaza antes de comprobarlo.
El proveedor se llama desde servidor; webhooks firmados, con ventana temporal y deduplicación. Un reintento HTTP o de outbox no debe multiplicar envíos facturados: se reutiliza la clave de idempotencia del proveedor o se reconcilia el estado antes de reenviar. Si el proveedor no permite resolver un resultado incierto, se marca pendiente y se aplica un nuevo envío explícito sujeto a límites. No se promete atomicidad entre PostgreSQL y un proveedor externo.
Cambiar o declarar perdido un número obliga a revalidar y puede suspender el chat; recuperar una cuenta no se basa únicamente en poseer un número reciclado. La base de políticas exige reverificación a los 180 días y tras recuperación sensible/cambio/compromiso. Compartir teléfono familiar no fusiona cuentas ni acredita tutela; cuotas y detección de abuso no se convierten en prueba de identidad. Faltan proveedor y prueba de sus capacidades reales, no una decisión de habilitar chat por defecto.
## 4. Comprobación de edad y supervisión
La aplicación necesita una prueba de elegibilidad, no un archivo con documentos de toda la población. La preferencia de diseño es un resultado mínimo de un verificador adecuado: umbral cumplido, emisor, fecha, caducidad y referencia verificable. Debe estar ligado al principal, propósito, audiencia y nonce de la solicitud para impedir usar la prueba de otra persona o repetir un callback.
Un callback firmado por el proveedor o una validación servidor a servidor puede completar la comprobación; el navegador no puede enviar `adult: true` como resultado. Una declaración puede activar inmediatamente la protección más restrictiva, pero nunca elevarla a adulto acreditado. Cambios o disputas sobre edad mantienen el modo seguro hasta resolverlos.
Antes de adoptar un proveedor se evalúan exactitud, fallos cerca del umbral, accesibilidad, sesgos, recurso/corrección, tratamiento de documentos/imágenes y supresión. No se incorpora por defecto biometría propia, copia de documentos ni inferencia conductual de edad. La [declaración del EDPB sobre comprobación de edad](https://www.edpb.europa.eu/documents/statement/statement-12025-on-age-assurance_en) sirve de referencia para necesidad, proporcionalidad y minimización; elegir SMS no satisface esa evaluación.
El vínculo con responsable requiere el flujo verificable y revocable definido en [P7](product-policies.md#4-edad-supervisión-y-comunicación--p7), con alcance limitado, evidencia de responsabilidad separada de edad adulta y autorización de contactos por principal. No se concede control por conocer nick, correo o teléfono. La supervisión informa de actividad y controles pertinentes; no abre chat ni expone información privada de otros jugadores. Los esquemas, evaluación de evidencia y procedimiento operativo deben implementarse antes de permitir cuentas infantiles. Las disputas no transfieren control automáticamente.
## 5. Protección en todas las superficies
| Superficie | Contrato |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Registro/invitados | Protección por defecto, explicación comprensible y minimización. Sin elevar permisos por cambiar fecha, borrar cookie o convertir invitado. |
| Catálogo | Juegos revisados para el público admitido. La política vive en el registro de publicación; no se inventa un campo del manifiesto del motor sin versionarlo. |
| Descubrimiento/perfiles | Sin búsqueda/listado público de menores, edad, contacto, localización o presencia infantil. Estadísticas privadas por defecto. |
| Nombres, títulos y avatares | Para menores/edad desconocida: alias, títulos y presets aprobados. El texto o imagen de un adulto no se muestra a menores sin proyección segura. Se incluyen nombres históricos de autor. |
| Invitaciones | No publican datos del menor; pertenencia y entorno se autorizan aparte del token. Sin contactos espontáneos adulto-menor ni aprobación implícita por enlace. |
| Salas | Proyección apta para cada destinatario. Un menor recibe movimientos y estados del juego, no chat adulto, mensajes de invitación sin revisar o medios arbitrarios. |
| Recursos y juegos futuros | Revisión de imágenes/audio, URLs, contenido editable, dibujos y texto libre. Un paquete no puede abrir un canal de conversación dentro de `game.*`. |
| Notificaciones/voz | Solo mensajes de sistema sobre el juego; sin extractos de chat ni invitaciones que permitan saltar permisos. No se incluye voz entre jugadores en v2. |
| Compartir/exportar | Se autoriza de nuevo y aplica redacción; no se exportan chats de menores ni perfiles de terceros por haber compartido sala. |
| Cliente y versiones antiguas | Mismos controles servidor. Un cliente incompatible debe actualizar; ocultar controles con CSS no satisface el requisito. |
| Moderación y soporte | Reportar, bloquear y pedir ayuda disponibles sin teléfono/chat. Acceso de personal restringido y auditado. |
| Analítica/publicidad | Sin publicidad personalizada, perfilado comercial ni publicación de edad/teléfono. Solo métricas operativas mínimas y agregadas. |
P7 limita menores a juego individual y salas privadas protegidas con contactos autorizados; no hay emparejamiento público infantil en este alcance. Invitados/edad desconocida permanecen en modalidad individual hasta completar requisitos. Revocación de tutela/contacto corta nuevas entradas/acciones y cancela de forma coordinada una partida afectada, sin resultado competitivo inventado. La ausencia de chat reduce contacto, pero no elimina abuso mediante nombres, imágenes o invitaciones.
Los recursos publicados en CDN y avatares públicos deben ser aptos para menores en esta versión. No basta con ocultar una tarjeta de catálogo: una URL pública sigue siendo accesible. Una futura distribución de contenido restringido exigiría autorización de archivos y tratamiento de caché propio, fuera del alcance inicial.
Las exportaciones compartibles de salas no son un atajo al historial de chat ni a datos ajenos. Una solicitud de acceso a datos personales propios se atiende por el proceso privado de gestión de datos, sin exigir SMS/chat; si requiere revisar contenido histórico, se tramita con comprobación de titularidad y redacción de terceros, sin abrir la funcionalidad social bloqueada.
No se añaden mensajes libres, emojis sociales o frases elegibles por usuarios a menores como excepción implícita. Si más adelante se aprueban expresiones predefinidas, necesitarán su propia política de frecuencia y abuso. Los textos del sistema sobre reglas y resultados sí forman parte del juego.
## 6. Bloqueo, reporte y atención
Toda persona puede bloquear/reportar desde una sala o un perfil permitido, y acceder a ayuda. Los reportes de seguridad no exigen SMS, edad adulta o aceptación de nuevas condiciones. La ruta para quien perdió acceso a su cuenta debe tener verificación proporcional, sin revelar datos del denunciado.
El bloqueo es privado. Impide nuevos encuentros/invitaciones dirigidas entre las cuentas y elimina futuras entregas de comunicación entre ellas. En chat de grupo se conserva continuidad mediante marcadores sin contenido para mensajes filtrados. Si ya comparten partida, el bloqueo oculta comunicación y datos sociales sin fabricar un ganador; la UI permite abandonar la vista inmediatamente y ofrece retirada/ayuda según el protocolo.
Un reporte identifica recurso/persona, categoría y nota opcional; la evidencia se referencia en servidor, sin pedir al menor que vuelva a subir contenido dañino. Estados: `received → triaged → actioned|dismissed → closed`, con reapertura/apelación vinculada. El denunciante ve su estado y respuesta pertinente, nunca investigación privada o datos de otros reportantes.
La [política de moderación](product-policies.md#7-moderación-administración-y-juego-limpio) fija roles, cola urgente, objetivos de primera valoración y apelación humana. La cobertura real, responsables y sustitutos deben asignarse antes de habilitar chat/encuentros públicos; no se anuncia vigilancia permanente sin disponer de ella. Las escaladas externas se definen según jurisdicción y caso, sin automatizar comunicaciones a terceros desde este documento.
## 7. Conservación y pruebas de aceptación
La [matriz de conservación](product-policies.md#9-datos-conservación-y-borrado--p8) fija plazos para OTP, teléfono, evidencia mínima de edad, supervisión, mensajes, reportes, auditoría y copias. Caducar un desafío elimina su secreto. Revocar teléfono o solicitar borrado elimina o restringe los datos que correspondan; una evidencia de reporte tiene finalidad y acceso separados, no retención indefinida por defecto. Proveedores, exportaciones y restauraciones quedan incluidos.
| Prueba | Resultado exigido |
| ------------------------------------------------------------------ | ------------------------------------------------------------------ |
| Menor con SMS válido o supuesto tutor | Chat sigue bloqueado. |
| Adulto declarado sin prueba, o edad desconocida | Chat bloqueado, sin pedir SMS como atajo. |
| Adulto acreditado sin SMS | Puede jugar según permisos; chat no disponible. |
| Adulto acreditado + SMS + membresía en sala adulta | Acceso solo al chat y período autorizados. |
| Adulto acreditado + SMS en sala protegida/mixta | Chat desactivado también para él. |
| Llamada directa a HTTP/WS/v1/historial | Mismas restricciones que interfaz; sin payloads privados. |
| Cambio de edad, número, suspensión o caducidad con varias pestañas | Suscripciones/colas afectadas se cancelan; nueva entrega denegada. |
| OTP vencido, repetido, de otra sesión, con intentos agotados | Ninguna verificación; límites permanecen. |
| Callback falso/repetido o SMS duplicado | No eleva privilegios ni duplica efecto/envío confirmado. |
| Token de edad emitido para otro principal/audiencia | Rechazo. |
| Nick, avatar, título o evento de juego usado como chat | No llega libremente al menor. |
| Menor sin teléfono desea reportar/bloquear | Puede hacerlo y recibe confirmación comprensible. |
| Restauración de copia con permisos antiguos | Restricciones reaplicadas antes de entregar contenido. |
## 8. Cómo lo abordan otras plataformas
Consulta de fuentes oficiales: 5 de octubre de 2026. Describen políticas publicadas, no una auditoría de su eficacia ni una recomendación de copiar todos sus métodos.
| Plataforma | Política publicada relevante | Aplicación a Juegoland |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Lichess | Kid Mode permite jugar y bloquea comunicación general; admite mensajes dentro de la misma clase y con su profesor. También restringe foros/blogs/streams/vídeos. [Fuente](https://lichess.org/page/kid-mode) | Separar jugar de comunicar. Aquí no adoptamos las excepciones de clase: el usuario ha desactivado chat para todos los menores. |
| ChessKid | Sin chat libre entre niños; interacción adulta ligada a tutela, expresiones predeterminadas y revisión de nombres. [Seguridad](https://www.chesskid.com/learn/articles/how-to-understand-chesskids-safety-features2), [funciones educativas](https://www.chesskid.com/learn/articles/chess-coaching-for-kids). Su ayuda de mayo de 2026 describe avatares personalizables con elementos aprobados y sin fotos personales. [Avatares](https://support.chesskid.com/en/articles/12315569-how-can-i-customize-my-avatar-on-chesskid) | Contenido social controlado y presets; ninguna foto libre visible a menores. Las expresiones sociales tampoco se habilitan aquí sin decisión aparte. |
| Roblox | Publicó en junio de 2026 cuentas Kids/Select, revisión adicional de juegos para menores de 16, comprobación de edad para chat y controles parentales. Mantiene restricciones por edad y región. [Fuente](https://about.roblox.com/es/newsroom/2026/06/age-based-roblox-kids-and-select-accounts-now-globally-available) | La comprobación de edad es distinta del teléfono. El catálogo también necesita política de idoneidad; no copiamos su chat por grupos ni asumimos que debamos usar reconocimiento facial. |
| Discord | Family Center ofrece controles de comunicación/filtros e información de actividad a responsables vinculados sin mostrarles el contenido de los mensajes. [Fuente](https://discord.com/safety/family-center-is-getting-an-update) | Supervisión con alcance definido, privacidad y reportes; un responsable adulto no recibe acceso ilimitado por defecto. |
La referencia europea propone combinar privacidad por defecto, reducción de contacto no solicitado, bloqueo/reportes y comprobación de edad proporcionada. Son dimensiones independientes; seguir una guía no equivale a certificar cumplimiento ([Comisión Europea](https://digital-strategy.ec.europa.eu/en/library/commission-publishes-guidelines-protection-minors)).
La exigencia de SMS de Juegoland es una decisión adicional del producto, no un sustituto de estas capas ni una política universal observada en las plataformas anteriores. NIST documenta riesgos de SMS/PSTN, entre ellos cambios de SIM y portabilidad; aquí se usa para comprobar posesión del número con límites y recuperación, sin presentarlo como prueba de edad ([NIST](https://pages.nist.gov/800-63-4/sp800-63b/authenticators/)).

@ -0,0 +1,230 @@
# Políticas de producto y operación — Juegoland v2
**Estado:** base de diseño desarrollada el 5 de octubre de 2026 por encargo del usuario. No está implementada ni constituye una certificación jurídica. Las decisiones expresas del usuario son: juegos propios publicados exclusivamente por el equipo, motor propio, protección transversal del menor, chat desactivado para menores y SMS obligatorio para quienes puedan usar chat. Los demás valores de este documento son decisiones de diseño propuestas con justificación y criterios de revisión.
Complementa el [modelo](../platform-spec-v2.md), la [API](api-and-events.md), la [protección del menor](child-safety.md) y la [arquitectura de tres capas](architecture.md). Sustituye los valores abiertos P1–P8 del primer borrador por una base concreta. Proveedores, presupuesto autorizado, responsables y evidencia de funcionamiento se resuelven antes de activar las capacidades correspondientes.
## 1. Alcance del primer lanzamiento
- Juegos propios, por turnos, casuales, con reglas y recursos revisados por el equipo. No hay publicación de usuarios, marketplace, plugins externos ni carga de código desde cuentas de jugadores.
- Catálogo, cuentas, juego individual, salas privadas y salas públicas para adultos acreditados; partidas privadas supervisadas para menores; historial privado, invitaciones, bloqueo, reportes y consola interna.
- Chat exclusivamente en salas configuradas para adultos acreditados y para miembros con SMS vigente. Las salas protegidas, incluidas las mixtas con menores, tienen comunicación desactivada para todos sus participantes. Es una decisión adicional de diseño: simplifica la supervisión y evita canales paralelos invisibles para parte de la mesa.
- Sin mensajes privados, voz, espectadores, búsqueda global de personas, amigos abiertos, torneos, ELO, apuestas, compras, publicidad o recompensas por tiempo de conexión. Añadirlos exige estudiar permisos y abuso antes de ampliar contratos.
- Primera política territorial diseñada para España; no se presupone lanzamiento mundial por tener la web accesible. La configuración de países admitidos y condiciones debe fijarse antes del registro público. Un país no configurado no hereda automáticamente las condiciones españolas; la IP es una señal auxiliar, no prueba de residencia.
## 2. Qué tomamos de otras plataformas y qué cambiamos
Fuentes oficiales consultadas el 5 de octubre de 2026. La columna de riesgos es nuestro análisis del diseño publicado, no una afirmación de vulnerabilidades demostradas en esos servicios. Los valores numéricos de Juegoland son propios salvo indicación expresa.
| Referencia | Práctica publicada | Riesgo o límite que evitamos en Juegoland |
| -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Lichess Kid Mode](https://lichess.org/page/kid-mode) | Permite jugar restringiendo comunicación; mantiene excepciones para la clase y su profesor. | Las excepciones complican la autorización. No habilitamos chat infantil por tutor, clase o sala privada. |
| [ChessKid: seguridad](https://www.chesskid.com/learn/articles/how-to-understand-chesskids-safety-features2) | Restringe chat entre niños y conexiones adulto-menor a relaciones de tutela. | Adoptamos un entorno de contactos autorizados, comprobando la relación por separado de la edad adulta. |
| [ChessKid: vinculación](https://support.chesskid.com/en/articles/8863569-how-do-i-connect-to-my-kid-s-account-through-guardianship) | Describe vinculación de cuentas mediante correspondencia del correo del responsable. | Una coincidencia de correo o un enlace reenviado no serán evidencia suficiente para tomar control de una cuenta infantil existente. |
| [Roblox: seguridad de chat](https://en.help.roblox.com/hc/en-us/articles/203313120-Safety-Features-Chat-Privacy-Filtering) | Filtrado de comunicación y medidas contra datos personales y salida a otras plataformas. | Filtrar palabras no basta: la restricción se aplica también a nombres, imágenes, recursos, invitaciones y contenido de juego. No prometemos que un filtro comprenda todos los abusos. |
| [Board Game Arena: FAQ](https://en.boardgamearena.com/faq) | Relojes, reputación y penalizaciones por abandonar; consecuencias competitivas de expulsar a quien excede tiempo. | En una plataforma casual no inferimos mala conducta ni derrota de una conexión caída. Separamos retirada, inactividad, fallo del servicio y sanción. |
| [Chess.com: abandono](https://support.chess.com/en/articles/8593801-how-does-game-abandonment-work) | Usa temporizadores de desconexión/actividad y explica que la cobertura Wi-Fi no demuestra conexión con el servidor. | Mostramos confirmación del servidor y estado de reconexión. No presentamos como ejecutada una jugada pendiente ni aplicamos tiempos competitivos ocultos a partidas casuales. |
| [Lichess: apelaciones](https://lichess.org/page/appeal) | Revisión humana por otro moderador y objetivo publicado de respuesta. | Definimos responsables, plazo y recurso junto a la sanción; no basta con un botón de denuncia. La falta de personal no se presenta como revisión independiente. |
| [Comisión Europea: protección del menor](https://digital-strategy.ec.europa.eu/en/library/commission-publishes-guidelines-protection-minors) | Privacidad por defecto, control del contacto y reducción de diseños que fomentan uso excesivo. | Sin rachas, presión para volver, avisos comerciales ni publicación de actividad infantil. La aplicación de estas guías no acredita por sí sola cumplimiento. |
## 3. Identidad, credenciales y recuperación — P6
### Estados y acceso
Estados de cuenta: `pending-email`, `active`, `restricted`, `deletion-pending`, `deleted`. Invitado es un tipo de principal independiente, no un adulto provisional. Verificación de correo, edad, teléfono, supervisión y restricciones tienen estados propios; ninguna columna `verified` los mezcla.
| Identidad | Acceso de diseño |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Sin cuenta | Catálogo seguro, reglas y demostraciones locales sin perfil social. |
| Invitado | Juego individual y alias/preset de sistema; sin multijugador, chat ni descubrimiento de personas. Identidad temporal recuperable solo desde su contexto de navegador. |
| Cuenta con correo pendiente o edad desconocida | Cuenta y privacidad propias; modalidad segura individual. No basta declarar una fecha para elevar permisos. |
| Adulto acreditado | Multijugador público/privado, condicionado por aceptación y restricciones; SMS solo para chat. |
| Menor con cuenta tutelada activa | Individual y salas privadas protegidas con participantes autorizados; sin comunicación ni ficha pública. |
Convertir invitado conserva `userId`, partidas y preferencias propias. Entrar en otra cuenta no fusiona cuentas ni ocupa automáticamente sus plazas. El invitado no obtiene privilegios de una sesión anterior del navegador.
### Credenciales
La base es correo verificado y contraseña de 15 a 128 puntos de código, normalizada NFC, sin truncar, sin reglas arbitrarias de composición ni renovación periódica. Se comprueban contraseñas comunes/comprometidas sin enviar su texto a un tercero. Hash Argon2id con sal aleatoria y parámetros calibrados/versionados para el hardware, respetando como mínimo la configuración de [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html); no se inventa un algoritmo propio. Las recomendaciones de longitud, gestores y cambio por compromiso se apoyan en [NIST SP 800-63B](https://pages.nist.gov/800-63-4/sp800-63b.html); los plazos y límites operativos siguientes son nuestros.
Passkeys son una evolución compatible para jugadores; el acceso administrativo requiere MFA resistente a phishing antes de producción. El PIN de cuatro cifras deja de servir como credencial remota. Migrar cuentas antiguas exige establecer credencial nueva y verificar un canal de recuperación; el correo antiguo no verificado no recibe poder para apropiarse de la cuenta sin comprobar titularidad.
Una cuenta infantil no necesita teléfono ni correo personal: recuperación por el responsable validado y credencial propia o emparejamiento de dispositivo de un solo uso autorizado por ese responsable. Un código de emparejamiento es aleatorio, caduca en 5 minutos, admite 5 intentos y no es una contraseña permanente. Su contrato ejecutable debe preceder a habilitar cuentas infantiles.
### Flujos y sesiones
1. Alta: contexto anónimo emitido por servidor, aceptación versionada y creación pendiente. Correo de verificación con token aleatorio de 256 bits, hash en servidor, uso único y vigencia de 24 h. La dirección no se muestra a terceros; solicitud repetida devuelve una respuesta genérica.
2. Login: valida credencial y restricciones; rota sesión y CSRF, no acepta un identificador de sesión elegido por el cliente. Reautenticación administrativa exige su segundo factor.
3. Recuperación: respuesta externa indistinguible exista o no cuenta; token aleatorio de 256 bits, uso único y vigencia de 30 min; permite restablecer credencial y luego iniciar sesión. No devuelve una sesión con permisos elevados en el enlace de correo.
4. Restablecer credencial revoca todas las sesiones y tokens de recuperación pendientes. Suspende comunicación y gestión de menores hasta revalidar sus pruebas pertinentes; poseer correo o un teléfono reciclado no restaura automáticamente esos permisos.
5. Cambio de correo: reautenticación reciente y verificación del nuevo correo; aviso al anterior, sin enlace que por sí solo conceda control de cuenta. Soporte no cambia edad, tutela o credenciales basándose únicamente en datos de perfil.
| Sesión / operación | Plazo propuesto |
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| Invitado | 24 h absolutas, 2 h inactivo; no prolongar por latidos. |
| Cuenta, modo normal | 12 h absolutas, 2 h inactivo. |
| «Recordarme», adulto y dispositivo personal | 30 días absolutos, 7 días inactivo; elección explícita. |
| Cuenta infantil o equipo compartido | Sin «recordarme» por defecto; 12 h absolutas y 1 h inactivo. |
| Administración | 8 h absolutas, 15 min inactivo, sin «recordarme». |
| Reautenticación para credenciales, teléfono, tutela, exportación o borrado | Últimos 5 min; se mantienen rutas de ayuda para quien no pueda autenticarse. |
Actividad es una interacción autenticada del usuario, no ping, polling o publicación servidor. Las páginas de verificación/recuperación no cargan terceros; secretos en fragmento y POST, fuera de logs, historial y analítica, con `no-store`.
Login, emisión de sesión y consumo de token necesitan un registro de intento específico. Ventana de recuperación de respuesta cifrada: 5 min, ligada al contexto y prueba original; repetir no crea otra sesión ni alarga su vida. Una sesión revocada no se reemite. Finalizada la ventana se exige nuevo login o recuperación. No almacenar contraseñas, OTP ni sesión en recibos generales. Cuotas iniciales: 5 fallos de acceso/15 min por cuenta y 30/IP, con espera progresiva; 3 correos de recuperación/hora por cuenta y 20/IP. No bloqueo indefinido activable por un atacante; respuestas con tiempos y contenido uniformes, límites globales y soporte accesible.
## 4. Edad, supervisión y comunicación — P7
El perfil de lanzamiento propone cuentas tuteladas de **6 a 17 años**, alta autónoma para adultos y demostración local sin cuenta por debajo de esa franja. Es una elección de producto inicial, no una edad mínima legal ni una obligación de tutela universal. La autonomía de adolescentes y la adecuación del límite inferior deben reevaluarse con pruebas de uso antes de ampliar el perfil.
En España, el [artículo 7 de la LOPDGDD](https://www.boe.es/buscar/act.php?id=BOE-A-2018-16673#a7) distingue el consentimiento para tratamiento de datos por debajo de catorce años y contempla excepciones. Esto no equivale a permiso general para chat, mayoría de edad, capacidad contractual o autorización de todo tratamiento. Cada finalidad debe tener base y alcance propios; no se usa «aceptar condiciones» como consentimiento universal.
- Una declaración de minoría activa restricciones inmediatamente. Acreditar adulto exige prueba del umbral de 18 años, vinculada a cuenta, propósito y solicitud. Sin prueba, se puede seguir en modo seguro individual.
- Se prefiere acreditación de umbral con mínima información, sin guardar DNI, selfie, fecha exacta de nacimiento ni biometría en Juegoland. La evaluación del proveedor incluye falsos positivos/negativos, accesibilidad, corrección, supresión y alternativa viable para adultos sin documentos compatibles. [EDPB: comprobación de edad](https://www.edpb.europa.eu/documents/statement/statement-12025-on-age-assurance_en).
- La validez interna de la prueba adulta se limita al menor de la caducidad del emisor y 12 meses. Caducidad, recuperación sensible o evidencia contradictoria suspenden privilegios afectados; cumplir años según un dato autodeclarado no los activa.
- Teléfono: reverificar a los 180 días y después de cambio, recuperación sensible o señal de compromiso. Se avisa antes de vencer; al vencer se corta chat, no la partida. La verificación nunca habilita una cuenta menor.
### Cuenta tutelada y contactos
El responsable necesita cuenta adulta acreditada, correo verificado y reautenticación reciente. Acreditar edad adulta no acredita potestad sobre un menor. El alta de la relación requiere evidencia adecuada de responsabilidad y consentimiento por finalidad: un servicio que soporte esa comprobación o un procedimiento humano definido y auditado. No basta conocer correo/nick, compartir teléfono o presentar una invitación. No se abre esta capacidad hasta disponer del procedimiento y su tratamiento de evidencias.
Estados: `pending → active → suspended|revoked`. Alta inicial por responsable crea la cuenta infantil; vincular una cuenta existente exige comprobación adicional del control previo y de la responsabilidad, nunca autoenlace por correo. Cambiar responsable exige revisión; mientras haya disputa se suspenden cambios de supervisión y nuevos encuentros, manteniendo ayuda y acceso a datos propios según corresponda.
El responsable aprueba relaciones dirigidas y revocables entre principales concretos; no crea un grupo social público. Si ambos participantes son menores, sus respectivos responsables deben autorizarlas. Una invitación a una sala solo funciona si las relaciones necesarias ya están activas. Un menor puede bloquear o rechazar a una persona autorizada; el responsable no anula ese bloqueo silenciosamente.
La sala protegida no aparece en listas públicas; todos sus participantes están autorizados para encontrarse entre sí. Proyecta alias/presets revisados también para adultos. No admite texto libre, fotos ni chat de ningún participante. Menores no pueden crear salas públicas ni transformar una privada en pública. Salas públicas/adultas no admiten menores aunque alguien comparta su token.
En este perfil inicial, el anfitrión responsable ocupa una plaza como jugador y debe estar autorizado para supervisar a los menores participantes. No se crea implícitamente un rol de espectador. Por tanto, una sala protegida de dos plazas admite responsable y menor; jugar exclusivamente entre dos menores requeriría ampliar el modelo de supervisión sin plaza y queda fuera de esta primera configuración. Si el anfitrión abandona expresamente una partida protegida activa se cancela por seguridad sin resultado competitivo; perder conexión solo inicia la recuperación normal y no equivale a abandonar.
Revocar supervisión o contacto impide nuevas entradas y nuevas acciones entre las personas afectadas. En una partida en curso se aplica una cancelación de seguridad coordinada con el núcleo, sin ganador artificial y con motivo público genérico; preserva lo necesario para revisión privada. Una emergencia no espera a que el jugador confirme una revisión obsoleta. La transacción interna revalida la revisión actual.
La vista del responsable muestra configuración, personas autorizadas, juegos y duración aproximada, no secretos de partidas ni conversaciones adultas. A los 18 años no hereda control de la cuenta: la emancipación requiere acreditar el nuevo umbral y establecer recuperación propia; se revoca acceso del responsable de forma atómica. No se recopila una fecha exacta solo para automatizar ese cambio.
## 5. Salas, invitaciones y continuidad — P1–P4
| Política | Base de diseño |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Inicio P1 | Automático al llenarse la capacidad configurada y marcarse todos listos; uno también es válido si el paquete lo permite. Cambios de participantes/configuración invalidan listo. |
| Anfitrión P2 | Al salir en espera, transferir al miembro elegible más antiguo, desempate por `membershipId`; si no queda elegible, cancelar. Revocar invitaciones del anterior anfitrión. En protegidas, anfitrión adulto responsable autorizado. |
| Configuración | Juego, versiones, tipo de admisión y comunicación se fijan al crear; capacidad y opciones admitidas solo cambian en espera. La UI presenta consecuencias antes de «listo». |
| Espera P3 | Sala pública caduca a los 30 min; privada a las 24 h desde creación. Ningún ping/chat la prolonga. El anfitrión puede crear otra explícitamente. |
| Partida P3 | Perfil casual inicial: cierre solicitado tras 10 min sin progreso, solo por quien no puede actuar mientras otro sí; cancelación automática tras 24 h sin progreso. Sin derrota por presencia perdida. |
| Variantes | El registro de publicación aprueba un perfil de duración por versión de juego. Un juego que necesite correspondencia o plazos diferentes no hereda silenciosamente 24 h: requiere otro perfil documentado antes de publicarse. |
| Invitación P4 | 24 h por defecto; máximo 7 días y siempre antes del vencimiento de sala. Un uso por defecto; límite de usos nunca superior a plazas libres al emitir. Revalidar capacidad al consumir. |
| Cuotas P4 | Máximo 5 salas en espera por adulto, 1 por responsable en nombre de cada menor, 10 invitaciones activas por sala y 20 emisiones/hora por anfitrión. Entrada idempotente no consume otro uso. |
| Revancha | Otra sala, otra invitación y aceptación explícita; no arrastra chat, tutela revocada ni participantes sin consentimiento. |
Cerrar pestaña, pasar a segundo plano o perder Wi-Fi no es retirada. Se permite reconectar desde otra sesión del mismo principal sin otro asiento. Una retirada explícita la resuelven las reglas del juego; la plataforma no adjudica automáticamente victoria al último conectado.
No hay sanciones de reputación por desconexión en v2 casual. Abuso repetido de creación/entrada puede limitarse por cuotas; sanciones por sabotaje requieren evidencia y recurso. Un bloqueo personal evita nuevos encuentros, pero por sí solo no concede una victoria ni permite expulsar a un rival durante una partida.
### Fallos del servicio
`lastProgressAt` cambia con progreso confirmado del motor. Para evaluar inactividad se excluyen intervalos de indisponibilidad del servicio confirmados por operación; se registran con alcance y revisión, sin que un cliente pueda declararlos. Antes de reactivar el programador tras un incidente se reconcilian los plazos y se concede al menos 10 min de margen para cierres de plataforma que hayan vencido durante el corte. Una ausencia de métricas no demuestra abandono: se retienen esos cierres hasta revisar el incidente.
No se reescribe el reloj de un juego competitivo inexistente en v2; si se introduce, tendrá política propia de incidentes. Una cancelación ya confirmada se conserva y solo admite enmienda auditada, nunca reapertura silenciosa. Se comunica por separado fallo de red, mantenimiento, partida bloqueada y cancelación.
## 6. Chat, presencia, notificaciones y bienestar — P5
Chat solo en salas adultas, texto plano de hasta 500 puntos de código/2 KiB, sin adjuntos, enlaces activos ni previsualizaciones externas. El servidor rechaza patrones claros de spam/datos de contacto y aplica moderación; no reescribe silenciosamente lo que quiso decir el autor ni considera el filtrado garantía suficiente. Un rechazo muestra una explicación y permite corregir como intención nueva.
El intervalo legible empieza al entrar en la sala y termina al retirarse o finalizar, sujeto a restricciones actuales y redacciones. No se comparte historial anterior a la admisión. Al finalizar, chat de solo lectura hasta su purga; no existe conversación persistente a través de revanchas. La pérdida de SMS o elegibilidad bloquea también historial.
Presencia solo dentro de salas autorizadas, sin «última vez» global, localización ni lista pública de menores. TTL vencido significa desconocido. Notificaciones de sistema sin fragmentos de chat, con destino autorizado al abrir. Sin correo/push comercial ni recordatorios de retorno; avisos de seguridad y recuperación usan su finalidad específica.
Preferencia de sonido y movimiento reducido, sin autoplay de audio antes de interacción. Aviso discreto de descanso a los 45 min de actividad local; descartable, sin sanción ni pérdida de partida. El responsable puede configurar una pausa futura: avisa antes y evita iniciar otra partida; no expulsa de una activa por sorpresa. No se exige una racha ni compartir datos para seguir jugando.
## 7. Moderación, administración y juego limpio
Roles internos separados: soporte de cuenta, moderación, publicación y operación. Acceso mínimo por función, MFA, reautenticación sensible y auditoría de consultas a evidencia. Publicar juegos no concede lectura de teléfonos o casos infantiles. El personal no puede impersonar usuarios ni jugar con una vista privilegiada; diagnósticos usan datos sintéticos o proyecciones mínimas.
Se prohíben acoso, amenazas, discriminación, petición de contacto/datos personales, captación, contenido sexual, spam, suplantación y manipulación deliberada del juego. Las reglas de ayuda externa se explican por modalidad: en multijugador sin acuerdo explícito, no motores ni asistencia que decida jugadas; práctica individual puede ofrecer ayudas declaradas. No se sanciona automáticamente por precisión, IP compartida o una anomalía estadística aislada.
| Prioridad | Tratamiento y objetivo propio |
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Urgente: posible daño a menor, amenaza o cuenta comprometida | Confirmación inmediata, posibilidad de bloqueo/cierre personal y alerta a guardia. Primera valoración humana objetivo ≤1 h durante capacidad social habilitada. |
| Acoso, contenido o sabotaje | Primera valoración ≤24 h; comunicación de decisión o estado ≤72 h. |
| Apelación/corrección de edad o sanción | Acuse inmediato; revisión humana por otra persona objetivo ≤7 días. No requiere chat ni SMS. |
Son objetivos de operación que deben dotarse de personal, no promesas de servicio ya existente. Antes de habilitar chat y encuentros públicos se asignan guardia y sustituto, calendario y escalado. Si no existe cobertura, esas capacidades permanecen apagadas; se puede lanzar juego individual y privado protegido con ayuda asincrónica y horario visible. Los reportes se reciben siempre; no prometer respuesta urgente fuera de la cobertura real.
Un reporte no produce sanción automática al denunciado. Restricciones preventivas requieren regla de riesgo explicable y trazabilidad; se separan de una decisión definitiva. Medidas: retirar contenido, limitar comunicación, suspensión temporal y cierre en casos graves/repetidos, con motivo, alcance y vencimiento. Revisión de urgencia no exige seguir una escalera antes de proteger.
La apelación se vincula al caso, permite aportar información sin publicar evidencia y no depende de mantener abierta una sesión social. Se admite durante 6 meses desde notificación, sin excluir solicitudes legales posteriores. Si solo hay un operador, se declara la limitación y se busca revisión distinta antes de habilitar una operación que prometa independencia. Las comunicaciones a autoridades o terceros tienen procedimiento jurídico/humano propio; no se envían automáticamente por este diseño.
## 8. Publicación interna de juegos
El equipo es el único autor y publicador. No hay endpoint de publicación para jugadores, acceso de desarrolladores externos ni sandbox de código subido por usuarios. Las dependencias de software y recursos reutilizados siguen requiriendo licencias y revisión; «propio» no equivale a ausencia de dependencias.
Flujo interno: `draft → validated → staged → published → retired|quarantined`. Los primeros estados son de preparación; los tres últimos son disponibilidad pública ya definida en el modelo. Un artefacto publicado es inmutable por digest.
Para publicar: validación estructural/semántica, inventario de recursos y licencias, pruebas de reglas y privacidad por asiento, conservación de versiones, reconexión, retirada, determinismo/azar, teclado/táctil/movimiento reducido, revisión de texto/imágenes/audio y ensayo de carga. El registro fija perfil de edad/contenido, duración, compatibilidad del motor y build de servidor que incluye sus reglas. Staging usa cuentas sintéticas, nunca sesiones de producción.
Se activa primero para pruebas internas, después gradualmente para nuevas salas. Las existentes conservan su versión. Retirar impide crear y permite terminar; poner en cuarentena bloquea ejecución y abre incidente, con reparación compatible o cancelación auditada sin inventar resultado. Revertir despliegue requiere conservar todas las reglas aún activas. Los assets públicos tienen nombre por digest; jamás incluyen reglas privadas, credenciales o estado oculto.
## 9. Datos, conservación y borrado — P8
Plazos base de producto, sujetos a concretar bases jurídicas por finalidad antes de producción; no se atribuyen a las plataformas consultadas. Cada purga tiene propietario operativo, métrica, propagación y prueba de restauración. No se mantiene todo indefinidamente «por seguridad».
| Categoría | Retención propuesta y eliminación |
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OTP y tokens de acción | Secreto/hash eliminados al consumirse, agotarse o vencer; metadatos mínimos de abuso 30 días. Respuesta cifrada de emisión de sesión máximo 5 min; invitación máximo 24 h. |
| Sesiones | Token revocado deja de autenticar inmediatamente; registro mínimo hasta 30 días después de caducar/revocar. Nunca token en claro en auditoría. |
| Invitados / altas incompletas | Invitados: purgar perfil a los 7 días de expirar y cerrar antes cualquier partida individual; alta no verificada: 7 días. No consumir una cuenta convertida con un job antiguo. |
| Teléfono / prueba de edad | Mientras habiliten una finalidad; teléfono cifrado se elimina en ≤24 h al revocar, salvo finalidad documentada distinta. Índice antiabuso HMAC 30 días; evidencia mínima de edad hasta 30 días tras caducidad/revocación. Sin documentos/biometría propios. |
| Supervisión / consentimientos | Vínculo activo mientras sea necesario; prueba mínima de consentimiento/actuación hasta 12 meses tras fin, con finalidad y revisión. No guardar documentos originales como registro permanente. |
| Chat | 30 días desde cada mensaje; páginas y eventos vivos respetan purga y redacciones. Evidencia seleccionada de un caso va a un almacén restringido independiente. |
| Salas terminales, estado y eventos de juego | 90 días tras terminar; después borrar estado privado, recibos asociados y relación detallada de participantes salvo caso justificado. |
| Resultados resumidos propios | 24 meses desde finalización, sin chat ni secretos. Después agregados solo si son realmente anónimos; pseudonimizar un UUID no basta. |
| Notificaciones | 90 días desde creación, leídas o no; las referencias purgadas muestran destino no disponible. |
| Reportes y apelaciones | 12 meses tras cierre definitivo, ampliación motivada/revisada por obligación o incidente. Aislamiento, registro de cada acceso y eliminación al terminar finalidad. |
| Sanciones y bloqueos | Restricción mientras esté activa; al vencer, metadatos mínimos hasta 12 meses. Bloqueos mientras exista la relación y cuenta; su baja no revela quién bloqueó. |
| Logs / auditoría | Logs operativos 14 días, seguridad 90 días, acciones administrativas 12 meses; sin payloads, OTP o pruebas privadas. |
| Outbox / recibos generales | Outbox confirmada 7 días; no confirmada genera alerta y reconciliación, no purga silenciosa. Recibos sala/juego 90 días tras terminal; chat 30 días; otros comandos 30 días. Sobres caducados no se reaplican tras purga. |
| Avatares | Solo revisados; eliminar original tras transformación, máximo 24 h. Reemplazados/eliminados: retirar origen y cachés controladas en ≤24 h; copias de terceros no son revocables. |
| Copias de seguridad | Ventana móvil máxima 35 días, cifrada y separada; sin volver a poner en servicio datos borrados al restaurar. |
Los originales de evidencia excepcional de tutela, si un procedimiento aprobado los requiere, se manejan en un canal restringido y se eliminan en ≤7 días tras decisión; se conserva solo resultado mínimo. Su necesidad y acceso deben justificarse antes de recogerlos.
Exportación: reautenticación/titularidad proporcional, preparación objetivo ≤7 días, descarga privada de un uso que caduca en 24 h, archivo eliminado en 7 días. Incluye datos propios y explica restricciones/redacciones de terceros; no exige SMS ni abre chat a menores. Las solicitudes legales siguen sus plazos aplicables aunque no haya cuenta activa.
Borrado: confirmación explícita y reautenticación; suspensión de sesiones y nuevas acciones inmediata, resolución coordinada de partidas activas, purga de datos ordinarios en ≤30 días. Sin período de espera obligatorio ni recuperación por un simple login. Retenciones excepcionales se segregan, justifican y comunican cuando proceda. Las copias expiran en ≤35 días adicionales respecto de una purga del origen, sin acceso ordinario.
Un registro mínimo independiente de supresión/restricción se conserva hasta que hayan caducado todas las copias que podrían restaurar esos datos. Toda restauración permanece cerrada al tráfico hasta reaplicar ese registro y las revocaciones vigentes. El simulacro incluye una cuenta borrada y una restricción posterior al backup. Una retención de caso no mantiene habilitada una cuenta ni autoriza uso comercial de su información.
## 10. Experiencia y accesibilidad
Se especificarán pantallas completas sobre estos recorridos: descubrir → elegir → configurar → aceptar listo → jugar → resultado; invitación → comprobación de acceso → entrada; alta/verificación/recuperación; tutela/contactos; denunciar/bloquear/apelar; exportar/borrar. Cada paso contempla carga, vacío, validación, permisos caducados, desconexión y error recuperable.
El objetivo es [WCAG 2.2 AA](https://www.w3.org/TR/WCAG22/): teclado, foco visible, instrucciones textuales, contraste, autenticación accesible y alternativas a arrastre/color/sonido. Interfaz de plataforma usable a 320 CSS px y zoom 200–400 %; tablero bidimensional con navegación propia sin estrechar el resto de la página. Probar navegadores móviles reales, teclado virtual, orientación, texto ampliado y modo de escritorio además de emulación.
La UI distingue acción pendiente y estado confirmado; no reproduce sonidos de todo el historial al reconectar. El frontend nunca calcula un permiso definitivo ni decide resultado/azar. Controles desactivados explican el requisito al propietario sin divulgar edad o sanción ajena. Las animaciones no bloquean plazos ni confirmaciones del servidor.
## 11. Operación y costes — P8
| Elemento | Perfil inicial de aceptación, aún sin medir |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Disponibilidad | Objetivo mensual 99,9 %, medido desde fuera de la LAN/proxy; registrar mantenimiento e incidentes. No equivale a SLA contractual. |
| Latencia | p95 en servidor <500 ms lectura y <1 s recibo; reconectar/sincronizar <5 s tras restablecer red después de 30 s de corte. SMS/edad se miden aparte. |
| Carga reproducible | 500 conexiones autenticadas, 100 partidas activas, 50 comandos/s sostenidos durante 30 min; ráfaga de reconexión de 500 clientes en 60 s. Fixtures públicos/protegidos y varias vistas por juego; registrar hardware, memoria, errores y p99. Es umbral de ensayo, no capacidad acreditada. |
| RPO / RTO | Pérdida recuperable objetivo ≤5 min / recuperación objetivo ≤60 min. Copia base diaria + archivo continuo WAL, cifrados y fuera del host; alerta por retraso >5 min. Simulacro mensual y tras cambios relevantes. |
| Despliegue | Artefactos reproducibles, staging, migraciones expand/contract, arranque automático y drenaje; readiness valida DB, versión de esquema y todos los módulos activos. No arrancar dos propietarios de una partida. |
| Diagnóstico | Sondas separadas de frontend, API, comandos, DB, proxy, DNS/TLS y recorrido externo. Cambiar Node por Go no demuestra ni arregla por sí mismo una avería de red. |
| Proveedores | Email, SMS y prueba de edad con timeout, dedupe/conciliación, métricas, circuit breaker y estado visible. Fallar en modo restringido sin cancelar juegos por una caída de SMS. |
| SMS | Destinos admitidos por política, cuotas de desafíos de protección y presupuesto finito configurado. Perfil candidato: 10 €/día y 50 €/mes, sin autorizar gasto ni contratar proveedor por documentarlo; límite efectivo cero hasta configurar precio y presupuesto autorizado. |
Antes de cada envío se reserva atómicamente coste conservador conocido; el callback concilia coste real. Al agotarse presupuesto, no se envía ni se cambia de proveedor silenciosamente; se preserva el estado verificable anterior hasta su caducidad. Disputas/costes desconocidos quedan reservados hasta reconciliar para impedir duplicados facturados.
Asignar responsable de operación, protección, privacidad y publicación, con sustituto y contacto interno. No inventar nombres ni cobertura. La consola muestra retraso de outbox, fallos de purga, edad de backups, disponibilidad de moderación, cuotas y errores de proveedor sin exponer secretos.
## 12. Dependencias de activación y aceptación
| Capacidad | Qué debe existir antes de habilitarla |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| Cuenta recuperable | Email contratado/configurado, credencial y sesiones implementadas, migración segura de PIN y pruebas de emisión/respuesta perdida. |
| Multijugador adulto | Acreditación de edad evaluada, reglas de admisión y revisión de privacidad; para encuentros públicos, bloqueo/reportes y cobertura operativa. |
| Cuenta infantil / salas protegidas | Procedimiento de tutela, contactos autorizados, mecanismo de acceso infantil, proyecciones seguras y cancelación por revocación. |
| Chat | Adulto + SMS + sala adulta + moderación operativa + retención, probados en HTTP, tiempo real, v1, exportaciones y cachés. |
| Publicación de juego | Flujo interno y comprobaciones de paquete, runtime y frontend; manifiesto/digest asociado a build instalable. |
| Producción v2 | Contratos ejecutables, Postgres real, migración ensayada, restauración con supresiones, observabilidad y capacidad medida. |
Pruebas mínimas adicionales: recuperar una cuenta no restaura chat/tutela automáticamente; tutor revocado no admite nuevos participantes; menor no entra con invitación adulta; chat previo a la admisión no se filtra; moderación y purga invalidan páginas retrasadas; un corte del servidor no provoca cierres retrospectivos injustificados; dos proveedores/callbacks no elevan permisos ni duplican gastos; v1 y v2 no eluden la misma política. Estas pruebas deben convertirse en contratos y pruebas de integración; este documento no afirma que hayan pasado.

@ -0,0 +1,76 @@
# Revisión de especificaciones de plataforma — 5 de octubre de 2026
Alcance: [modelo de plataforma](../platform-spec-v2.md), [API/eventos](api-and-events.md) y su encaje con el motor. Durante la revisión el usuario añadió protección transversal del menor, SMS obligatorio para chat y confirmó **chat desactivado para menores**. Esos requisitos se desarrollan en [protección del menor](child-safety.md), con comparación de políticas publicadas.
Esta revisión identifica defectos de **diseño documental**, no vulnerabilidades explotadas ni correcciones desplegadas. «Corregido» significa que la propuesta ya expresa la regla; aún faltan implementación, esquemas y pruebas de comportamiento.
## Hallazgos y resolución
P1: riesgo de privacidad, doble efecto o estado inconsistente. P2: ambigüedad o falta que impide implementar/comprobar correctamente.
| ID | Prioridad | Fallo del borrador anterior | Resolución incorporada y comprobación pendiente |
| --- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| F01 | P1 | Retirada/cierre comprobaban solo revisión de sala; una jugada no necesariamente la cambia. No se fijaba frontera transaccional sala-motor. | Revisión adicional de partida, orden de bloqueo común y transacción compartida. Probar jugada frente a retirada/cierre y rollback de setup. |
| F02 | P1 | Se podía considerar corrupta una sala/partida por recibir sus eventos en orden inverso. | Barrera `minimumRevision`, estados independientes y recuperación del flujo atrasado. Probar ambas órdenes de entrega. |
| F03 | P1 | Moderar reemplazando un mensaje sin una secuencia nueva no actualizaba a clientes; páginas retrasadas podían reponer el texto. | Flujo de eventos de chat, redacción con nueva secuencia, `historyEpoch` y rechazo de páginas antiguas. Probar moderación concurrente con historial. |
| F04 | P1 | Idempotencia HTTP de 24 h sin límite de antigüedad permitía repetir una creación al purgar el recibo; faltaban colisión concurrente y propietario anónimo. | Fecha de emisión, ventana de admisión, restricción única, registro común por principal y contexto anónimo para altas. Probar repetición tardía y simultánea. |
| F05 | P1 | Invitación «token una vez/solo hash» era incompatible con recuperar una respuesta perdida. | Respuesta secreta cifrada temporal con alcance, caducidad y rotación explícita fuera de ventana. Probar pérdida de respuesta y cambio de anfitrión. |
| F06 | P1 | Exigir membresía para consultar cualquier operación impedía recuperar el recibo de una salida ya aplicada. | Recibo mínimo por principal emisor, sin devolver vista privada ni recuperar membresía. Probar salida y consulta desde otra sesión propia. |
| F07 | P1 | Autorización y revocación no contemplaban sesiones o publicaciones ya preparadas, ni protección infantil. | Política común, `safetyRevision`, cancelación de suscripciones y reautorización antes de entrega; menores/edad desconocida sin chat incluso en v1. Probar varias pestañas y outbox pendiente. |
| F08 | P2 | La matriz confundía invitado/cuenta con miembro/anfitrión y producía permisos contradictorios. | Ejes independientes y requisitos por operación; invitado puede ser anfitrión, pero no eludir edad/SMS. |
| F09 | P2 | Faltaban cancelar/configurar, revocar/rotar invitación, revocar condiciones/sesión y recuperar envíos de chat. | Operaciones y consulta común de recibos explícitas. Las credenciales/proveedor de edad permanecen señalados como pendientes, no simulados. |
| F10 | P2 | Solo podían jugar solos paquetes con `min=max=1`; inicio/fijación de versión aparecía en dos momentos. | Capacidad dentro del rango; pin exacto al crear, congelación de configuración al iniciar, invalidación de listo y setup atómico. |
| F11 | P2 | La política de inactividad no decía qué actividad contaba ni cómo evitar cierres obsoletos o reglas acopladas a `state.turn`. | Política versionada, progreso del motor, actores internos, generación y revalidación bajo bloqueo. No cuentan chat ni presencia. |
| F12 | P2 | Negociación dentro de `room.sync` no cubría clientes sin sala; faltaban recibos de baja, cursor de chat y límites de buffers. | Negociación de conexión, tipos correlacionados, sincronización acotada, cursores y backpressure. |
| F13 | P2 | La sincronización de chat retenía eventos mientras se paginaba un historial sin operación de continuación definida. | Snapshot de cola reciente con barrera; historial HTTP aparte, sin bloquear flujo vivo hasta leerlo entero. |
| F14 | P2 | Dedupe de avisos omitía destinatario; leerlos en una pestaña no tenía revisión. Un aviso de lobby podía revelar actividad privada. | Dedupe por destinatario/transición, revisión de bandeja al leer y señal solo por cambios públicos. |
| F15 | P2 | «Todos los IDs son UUID» contradecía `gameId`/asientos del motor. Campos de autoridad prohibidos también podían confundirse con destinatarios válidos. | Excepciones tipadas y distinción actor/objetivo. |
| F16 | P2 | 428 se reutilizaba para condiciones de uso; caché, errores, cursores y campos desconocidos eran ambiguos. | 403 para falta de aceptación, 409 para versión cambiada, revisiones de dominio y política separada de solicitud/respuesta. Referencia RFC 6585. |
| F17 | P1 | No se había especificado protección del menor ni diferencia entre teléfono, identidad, edad y consentimiento. | Requisito nuevo confirmado: no chat para menores; comprobación independiente de edad/SMS, restricciones en todo contenido/medio, reportes sin SMS y pruebas de evasión. |
| F18 | P2 | Recursos retirados parecían volverse privados; faltaban cuarentena, propiedad/validación de avatares y autoridad de publicación. | Política de distribución separada de contenido inmutable, publicación de confianza, medios propios validados y presets seguros. |
| F19 | P2 | Inmutabilidad de historial podía interpretarse como conservación personal indefinida; outbox y auditoría estaban mezcladas. | Finalidades/retenciones independientes, enmiendas auditables, anonimización y reaplicación de borrados tras restaurar. |
| F20 | P2 | SLO sin punto de medida/carga, migración sin autoridad única y recuperación/rollback no verificables. | Métricas servidor/exterior separadas, carga por definir, expansión/contracción, mapeo de IDs y cero doble escritura autónoma. RPO/RTO siguen pendientes. |
## Carencias que no resuelve editar Markdown
Actualización posterior por encargo del usuario: [políticas de producto](product-policies.md) desarrolla P1–P8 y compara fuentes oficiales; [arquitectura](architecture.md) separa las tres capas y evalúa Go. Publicación de juegos exclusivamente propios confirmada. Ya no faltan todos los valores de producto, pero sí evidencia de proveedores, capacidad operativa, contratos ejecutables e implementación.
| Pendiente | Entregable necesario |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Credenciales y recuperación P6 | Correo/contraseña, estados y sesiones ya propuestos; faltan esquemas, proveedor, migración PIN y pruebas de emisión/recuperación. |
| Edad y acceso de menores P7 | Perfil España/tutela/salas protegidas definido; faltan evaluación territorial, proveedor de edad, procedimiento de evidencia de responsabilidad y validación real. |
| SMS | Plazos y límites definidos; faltan proveedor, precios/presupuesto autorizado, dedupe/conciliación y pruebas de webhooks. |
| Moderación | Roles, acciones y objetivos definidos; faltan consola, personal/cobertura, procedimiento de escalado y revisión independiente operativa. |
| Conservación/operación P8 | Matriz y objetivos RPO/RTO/carga definidos; faltan responsables, fundamento por finalidad, ejecución de purgas y ensayos medidos. |
| Contratos ejecutables | Fuente común de tipos, OpenAPI, esquemas de eventos, fixtures válidos/negativos y comprobación CI. |
| Integración del motor | Unidad de trabajo compartida, cancelación de núcleo, configuración de preparación/asientos y mapeo de versiones antiguas. |
| Dirección Go | API Go fijada; servidor Go recomendado. SDK nativo, binding de transporte, paquete versionado y equivalencia de reglas pendientes antes de portar producción. |
| Validación real | Concurrencia, privacidad, reconexión, abuso, SMS, pruebas móviles/accesibilidad y ensayos con PostgreSQL/proxy. |
Los requisitos confirmados de protección no dependen de aprobar una excepción de chat infantil: esa excepción no existe. Mientras falten pruebas de edad/mecanismos operativos, no se habilita comunicación por suposición.
## Hallazgos de la ampliación de políticas y arquitectura
| ID | Problema o riesgo de diseño | Resolución documental / aceptación pendiente |
| --- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| F21 | «Publicador de confianza» podía interpretarse como apertura a autores externos. | Solo equipo y juegos propios; flujo interno, sin API pública de publicación. |
| F22 | Tres capas podían convertirse en dos escritores independientes de sala/partida. | API y servidor modular Go recomendado, unidad de trabajo común; puente Node con autoridad única si se necesita. |
| F23 | Confundir formato/protocolo JSON con compatibilidad ejecutable del SDK TS en Go. | Diferenciar datos, firmas y runtime; perfil de paquete versionado y fixtures de equivalencia antes de portar. |
| F24 | Sustituir Socket.IO por WebSocket sin adaptar handshake/cliente/recuperación. | Transporte actual preservado en su contrato; nuevo binding requiere especificación y pruebas, sin compatibilidad fingida. |
| F25 | SMS, correo, edad adulta o token podían interpretarse como evidencia de tutela o recuperación suficiente. | Evidencias y finalidades separadas, recuperación suspende capacidades sensibles, contactos autorizados y recurso. |
| F26 | Las políticas de desconexión podían sancionar fallos del servicio o ejecutar cierres vencidos durante una caída. | Sin penalización por presencia, ventanas de incidente y reconciliación de trabajos antes de reactivar cierres. |
| F27 | Historial de chat sin límite inferior y purga sin invalidación podían divulgar conversaciones previas o resucitar texto. | Intervalo por membresía, purga a 30 días y aumento de epoch/invalidación; pruebas de páginas retrasadas. |
| F28 | Objetivos de moderación/seguridad sin personas, presupuesto o backups medidos podían presentarse como capacidad existente. | Dependencias de activación, perfiles de ensayo y distinción entre objetivo y resultado observado. |
Las limitaciones observadas al comparar políticas de otras plataformas se distinguen de incidentes demostrados; no se ha auditado su implementación. Las fuentes y decisiones propias están junto a cada comparación en las políticas.
## Fuentes y criterio de revisión
- [Socket.IO: delivery guarantees](https://socket.io/docs/v4/delivery-guarantees/): sustenta la necesidad de recuperación a nivel de aplicación.
- [PostgreSQL: explicit locking](https://www.postgresql.org/docs/current/explicit-locking.html): orden consistente y transacciones acotadas.
- [RFC 6585, sección 3](https://www.rfc-editor.org/rfc/rfc6585.html#section-3): significado de 428.
- [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) y [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785.html): errores HTTP y JSON canónico.
- [OWASP: session management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html): sesión y revocación servidor.
- Las fuentes de edad, SMS y la comparación con Lichess, ChessKid, Roblox y Discord figuran junto a las afirmaciones en [protección del menor](child-safety.md#8-cómo-lo-abordan-otras-plataformas).
La revisión toma estos textos como referencias técnicas y políticas publicadas, no como prueba de cumplimiento de Juegoland ni auditoría de terceros.

@ -0,0 +1,17 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#eef3fb" />
<meta
name="description"
content="Juegoland: juegos para compartir en una misma mesa, estés donde estés."
/>
<title>Juegoland — Juegos para compartir</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,24 @@
{
"name": "games2-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"dev": "vite --host 127.0.0.1 --port 4174 --strictPort",
"check": "svelte-check --tsconfig ./tsconfig.json",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1 --port 4175 --strictPort"
},
"dependencies": {
"svelte": "5.57.1"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "7.3.1",
"svelte-check": "4.7.6",
"typescript": "6.0.3",
"vite": "8.3.2"
}
}

@ -0,0 +1,119 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchCatalog, type GameCard } from './lib/api';
type Status = 'loading' | 'ready' | 'error';
let status = $state<Status>('loading');
let games = $state<GameCard[]>([]);
let controller: AbortController | undefined;
async function loadGames() {
controller?.abort();
controller = new AbortController();
status = 'loading';
try {
games = await fetchCatalog(controller.signal);
status = 'ready';
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return;
status = 'error';
}
}
onMount(() => {
void loadGames();
return () => controller?.abort();
});
</script>
<svelte:head>
<meta name="color-scheme" content="light" />
</svelte:head>
<div class="shell">
<header class="site-header">
<a class="brand" href="/" aria-label="Juegoland, inicio">
<span class="brand-mark" aria-hidden="true"
><i></i><i></i><i></i><i></i></span
>
<span>Juegoland</span>
</a>
<nav aria-label="Principal">
<a href="#juegos">Juegos</a>
</nav>
</header>
<main>
<section class="intro" aria-labelledby="intro-title">
<div class="intro-copy">
<p class="intro-note">Una partida empieza aquí</p>
<h1 id="intro-title">Siempre hay sitio para una partida.</h1>
<p class="intro-description">
Juegos para compartir en una misma mesa, estés donde estés. Estamos
preparando los primeros para que puedas jugar con tu gente.
</p>
<a class="primary-link" href="#juegos">Ver los juegos</a>
</div>
<div class="game-scene" aria-hidden="true">
<div class="board">
{#each Array.from({ length: 16 }, (_, index) => index) as index}
<span
class:deep={(Math.floor(index / 4) + index) % 2 === 0}
class="square"
></span>
{/each}
<span class="piece piece-blue"></span>
<span class="piece piece-coral"></span>
<span class="piece piece-gold"></span>
</div>
</div>
</section>
<section id="juegos" class="games-section" aria-labelledby="games-title">
<div class="section-header">
<div>
<h2 id="games-title">Elige un juego</h2>
<p>Los juegos disponibles aparecerán aquí.</p>
</div>
{#if status !== 'loading'}
<button
class="refresh-button"
type="button"
onclick={() => void loadGames()}
>
Actualizar juegos
</button>
{/if}
</div>
{#if status === 'loading'}
<p class="catalog-message" role="status">Cargando juegos…</p>
{:else if status === 'error'}
<p class="catalog-message" role="alert">
No hemos podido cargar los juegos. Comprueba la conexión y vuelve a
intentarlo.
</p>
{:else if games.length === 0}
<p class="catalog-message" role="status">
Los primeros juegos estarán disponibles pronto. Vuelve más adelante.
</p>
{:else}
<div class="games-grid">
{#each games as game (game.id + '@' + game.version)}
<article class="game-card">
<h3>{game.title}</h3>
<p>{game.summary}</p>
<span>
{game.minPlayers === game.maxPlayers
? `${game.minPlayers} jugadores`
: `${game.minPlayers}–${game.maxPlayers} jugadores`}
</span>
</article>
{/each}
</div>
{/if}
</section>
</main>
<footer><span>Juegoland</span><span>Juegos para compartir.</span></footer>
</div>

@ -0,0 +1,45 @@
export interface GameCard {
id: string;
version: string;
title: string;
summary: string;
minPlayers: number;
maxPlayers: number;
}
function isGameCard(value: unknown): value is GameCard {
if (!value || typeof value !== 'object') return false;
const game = value as Record<string, unknown>;
return (
typeof game.id === 'string' &&
typeof game.version === 'string' &&
typeof game.title === 'string' &&
typeof game.summary === 'string' &&
typeof game.minPlayers === 'number' &&
typeof game.maxPlayers === 'number'
);
}
export async function fetchCatalog(signal?: AbortSignal): Promise<GameCard[]> {
const response = await fetch('/api/v2/catalog', {
headers: { Accept: 'application/json' },
signal,
});
if (!response.ok) {
throw new Error('CATALOG_UNAVAILABLE');
}
const body: unknown = await response.json();
if (!body || typeof body !== 'object') {
throw new Error('CATALOG_INVALID');
}
const data = (body as Record<string, unknown>).data;
if (!data || typeof data !== 'object') {
throw new Error('CATALOG_INVALID');
}
const items = (data as Record<string, unknown>).items;
if (!Array.isArray(items) || !items.every(isGameCard)) {
throw new Error('CATALOG_INVALID');
}
return items;
}

@ -0,0 +1,10 @@
import { mount } from 'svelte';
import App from './App.svelte';
import './styles.css';
const target = document.getElementById('app');
if (!target) {
throw new Error('No se encontró el contenedor de la aplicación');
}
mount(App, { target });

@ -0,0 +1,334 @@
:root {
font-family: 'Trebuchet MS', 'Segoe UI', sans-serif;
color: #18243c;
background: #eef3fb;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-width: 320px;
}
button,
a {
font: inherit;
}
a {
color: inherit;
}
:focus-visible {
outline: 3px solid #dd523e;
outline-offset: 4px;
}
.shell {
width: min(100%, 1440px);
min-height: 100vh;
margin: 0 auto;
padding: 0 clamp(20px, 5vw, 80px);
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: 104px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
text-decoration: none;
font-size: clamp(22px, 2.6vw, 29px);
font-weight: 900;
letter-spacing: -0.055em;
}
.brand-mark {
display: grid;
grid-template-columns: repeat(2, 9px);
gap: 3px;
transform: rotate(-8deg);
}
.brand-mark i {
display: block;
width: 9px;
height: 9px;
border-radius: 3px;
background: #2352c6;
}
.brand-mark i:nth-child(2) {
background: #e95c47;
}
.brand-mark i:nth-child(3) {
background: #efba42;
}
.brand-mark i:nth-child(4) {
background: #4bb89e;
}
.site-header nav a {
text-decoration-thickness: 2px;
text-underline-offset: 6px;
font-weight: 700;
}
.intro {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(290px, 0.82fr);
align-items: center;
gap: clamp(28px, 5vw, 80px);
min-height: 560px;
padding: 45px 0 80px;
}
.intro-copy {
max-width: 670px;
position: relative;
z-index: 1;
}
.intro-note {
color: #2352c6;
font-weight: 800;
font-size: 16px;
margin: 0 0 20px;
}
h1 {
font-size: clamp(48px, 7vw, 94px);
line-height: 1.02;
letter-spacing: -0.075em;
margin: 0;
max-width: 10ch;
}
.intro-description {
max-width: 51ch;
margin: 28px 0 30px;
font-size: clamp(18px, 1.8vw, 21px);
line-height: 1.55;
color: #43516b;
}
.primary-link {
display: inline-flex;
justify-content: center;
align-items: center;
min-height: 52px;
padding: 12px 24px;
border-radius: 15px;
background: #2352c6;
color: white;
font-weight: 800;
text-decoration: none;
}
.primary-link:hover {
background: #173e9b;
}
.game-scene {
display: grid;
place-items: center;
min-width: 0;
padding: 10px;
}
.board {
position: relative;
display: grid;
grid-template-columns: repeat(4, 1fr);
width: min(100%, 490px);
aspect-ratio: 1;
padding: 18px;
border-radius: 30px;
background: #fff;
box-shadow: 0 24px 60px #253f7024;
transform: rotate(7deg);
}
.square {
background: #dbe7fa;
}
.square.deep {
background: #b7cff5;
}
.square:first-child {
border-top-left-radius: 12px;
}
.square:nth-child(4) {
border-top-right-radius: 12px;
}
.square:nth-child(13) {
border-bottom-left-radius: 12px;
}
.square:nth-child(16) {
border-bottom-right-radius: 12px;
}
.piece {
position: absolute;
display: block;
width: 17%;
aspect-ratio: 1;
border-radius: 50%;
border: 8px solid #ffffffc9;
box-shadow:
0 8px 18px #18243c38,
inset 0 -7px 0 #00000019;
}
.piece-blue {
top: 27%;
left: 28%;
background: #2455d6;
}
.piece-coral {
top: 52%;
left: 54%;
background: #ec6754;
}
.piece-gold {
top: 27%;
left: 54%;
background: #ecc04f;
}
.games-section {
border-top: 2px solid #cbd8ed;
padding: 42px 0 100px;
min-height: 290px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: start;
gap: 24px;
}
h2 {
font-size: clamp(32px, 4vw, 45px);
line-height: 1.1;
letter-spacing: -0.055em;
margin: 0 0 8px;
}
.section-header p {
margin: 0;
line-height: 1.5;
color: #52617c;
}
.refresh-button {
cursor: pointer;
background: transparent;
border: 2px solid #2352c6;
color: #2352c6;
border-radius: 12px;
padding: 10px 16px;
font-weight: 700;
white-space: nowrap;
}
.refresh-button:hover {
background: #deebff;
}
.catalog-message {
max-width: 58ch;
padding: 28px;
margin: 30px 0 0;
border-left: 5px solid #e7b741;
border-radius: 0 12px 12px 0;
background: #ffffffaa;
line-height: 1.55;
color: #43516b;
}
.games-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 270px), 1fr));
gap: 18px;
margin-top: 28px;
}
.game-card {
padding: 26px;
border-radius: 16px;
background: white;
}
.game-card h3 {
font-size: 24px;
margin: 0 0 10px;
}
.game-card p {
color: #52617c;
line-height: 1.5;
}
.game-card span {
color: #2352c6;
font-weight: 700;
}
footer {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 24px 0 32px;
border-top: 1px solid #cbd8ed;
color: #52617c;
font-size: 14px;
}
@media (max-width: 850px) {
.intro {
grid-template-columns: 1fr;
gap: 42px;
padding-top: 36px;
}
.intro-copy {
max-width: 700px;
}
.board {
width: min(72vw, 420px);
}
}
@media (max-width: 520px) {
.site-header {
min-height: 78px;
}
.intro {
min-height: 0;
gap: 24px;
padding: 38px 0 56px;
}
.intro-note {
margin-bottom: 14px;
}
h1 {
font-size: clamp(43px, 12vw, 60px);
}
.intro-description {
margin: 21px 0 24px;
font-size: 17px;
}
.game-scene {
padding: 0 24px;
}
.board {
width: min(72vw, 290px);
padding: 11px;
border-radius: 20px;
}
.piece {
border-width: 5px;
}
.games-section {
padding: 32px 0 68px;
}
.section-header {
flex-direction: column;
}
.catalog-message {
padding: 18px;
}
footer {
flex-direction: column;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"useDefineForClassFields": true,
"types": ["vite/client"],
"skipLibCheck": true,
"strict": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
}

@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
server: {
host: '127.0.0.1',
port: 4174,
strictPort: true,
proxy: {
'/api': 'http://127.0.0.1:8082',
},
},
preview: {
host: '127.0.0.1',
port: 4175,
strictPort: true,
},
});

@ -0,0 +1,3 @@
module juegoland/games2
go 1.26.0

@ -0,0 +1,118 @@
package httpapi
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"juegoland/games2/internal/domain/catalog"
"juegoland/games2/internal/server"
)
type Handler struct {
service *server.Service
}
type gameView struct {
ID string `json:"id"`
Version string `json:"version"`
Title string `json:"title"`
Summary string `json:"summary"`
MinPlayers int `json:"minPlayers"`
MaxPlayers int `json:"maxPlayers"`
}
type response[T any] struct {
RequestID string `json:"requestId"`
Data T `json:"data"`
}
type catalogData struct {
Items []gameView `json:"items"`
NextCursor *string `json:"nextCursor"`
}
func NewHandler(service *server.Service) http.Handler {
h := &Handler{service: service}
mux := http.NewServeMux()
mux.HandleFunc("GET /live", h.live)
mux.HandleFunc("GET /ready", h.ready)
mux.HandleFunc("GET /api/v2/bootstrap", h.bootstrap)
mux.HandleFunc("GET /api/v2/catalog", h.catalog)
return mux
}
func (h *Handler) live(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "live"})
}
func (h *Handler) ready(w http.ResponseWriter, _ *http.Request) {
// This only asserts that the bootstrap server can serve its current
// in-memory catalog. PostgreSQL and games are not enabled in this phase.
writeJSON(w, http.StatusOK, map[string]string{"status": "ready", "phase": "foundation"})
}
func (h *Handler) bootstrap(w http.ResponseWriter, _ *http.Request) {
id, ok := newRequestID(w)
if !ok {
return
}
writeJSON(w, http.StatusOK, response[server.Bootstrap]{RequestID: id, Data: h.service.Bootstrap()})
}
func (h *Handler) catalog(w http.ResponseWriter, _ *http.Request) {
id, ok := newRequestID(w)
if !ok {
return
}
games := h.service.PublishedGames()
items := make([]gameView, 0, len(games))
for _, game := range games {
items = append(items, toGameView(game))
}
writeJSON(w, http.StatusOK, response[catalogData]{
RequestID: id,
Data: catalogData{Items: items, NextCursor: nil},
})
}
func toGameView(game catalog.Game) gameView {
return gameView{
ID: game.ID,
Version: game.Version,
Title: game.Title,
Summary: game.Summary,
MinPlayers: game.MinPlayers,
MaxPlayers: game.MaxPlayers,
}
}
func newRequestID(w http.ResponseWriter) (string, bool) {
var bytes [16]byte
if _, err := rand.Read(bytes[:]); err != nil {
http.Error(w, "Unable to create request ID", http.StatusInternalServerError)
return "", false
}
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
var encoded [36]byte
hex.Encode(encoded[0:8], bytes[0:4])
encoded[8] = '-'
hex.Encode(encoded[9:13], bytes[4:6])
encoded[13] = '-'
hex.Encode(encoded[14:18], bytes[6:8])
encoded[18] = '-'
hex.Encode(encoded[19:23], bytes[8:10])
encoded[23] = '-'
hex.Encode(encoded[24:36], bytes[10:16])
return string(encoded[:]), true
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}

@ -0,0 +1,68 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"juegoland/games2/internal/domain/catalog"
"juegoland/games2/internal/server"
)
func TestPublishedCatalogIsTheOnlyPublicCatalog(t *testing.T) {
registry := catalog.NewRegistry([]catalog.Game{
{ID: "draft", Title: "Borrador", Published: false},
{ID: "conecta4", Version: "1.0.0", Title: "Conecta 4", Summary: "Cuatro fichas en línea", MinPlayers: 2, MaxPlayers: 2, Published: true},
})
handler := NewHandler(server.NewService(registry, time.Now))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v2/catalog", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", recorder.Code)
}
if recorder.Header().Get("Cache-Control") != "no-store" {
t.Fatal("catalog response must not be cached before audience policies exist")
}
var body struct {
RequestID string `json:"requestId"`
Data struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.RequestID == "" || len(body.Data.Items) != 1 || body.Data.Items[0].ID != "conecta4" {
t.Fatalf("unexpected catalog response: %+v", body)
}
}
func TestBootstrapDoesNotAdvertiseUnavailableFeatures(t *testing.T) {
handler := NewHandler(server.NewService(catalog.NewRegistry(nil), func() time.Time {
return time.Date(2026, time.October, 5, 12, 0, 0, 0, time.UTC)
}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v2/bootstrap", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", recorder.Code)
}
var body struct {
Data server.Bootstrap `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Data.Features.Rooms || body.Data.Features.Chat || !body.Data.Features.Catalog {
t.Fatalf("incorrect advertised features: %+v", body.Data.Features)
}
if len(body.Data.PlatformProtocolVersions) != 0 || len(body.Data.GameProtocolVersions) != 0 {
t.Fatal("unimplemented realtime protocols must not be advertised")
}
if body.Data.ServerTime.Format(time.RFC3339) != "2026-10-05T12:00:00Z" {
t.Fatalf("wrong server time: %s", body.Data.ServerTime)
}
}

@ -0,0 +1,48 @@
package catalog
import "slices"
// Game describes an installed, internally published version. The registry
// never imports manifests from a player request or the legacy application.
type Game struct {
ID string
Version string
Title string
Summary string
MinPlayers int
MaxPlayers int
Published bool
}
type Registry struct {
games []Game
}
func NewRegistry(games []Game) *Registry {
return &Registry{games: slices.Clone(games)}
}
func (r *Registry) Published() []Game {
result := make([]Game, 0)
for _, game := range r.games {
if game.Published {
result = append(result, game)
}
}
slices.SortFunc(result, func(a, b Game) int {
if a.Title < b.Title {
return -1
}
if a.Title > b.Title {
return 1
}
if a.ID < b.ID {
return -1
}
if a.ID > b.ID {
return 1
}
return 0
})
return result
}

@ -0,0 +1,3 @@
// Package chat will own adult-only room communication and redactions.
// It is disabled until eligibility and moderation are operational.
package chat

@ -0,0 +1,3 @@
// Package identity will own accounts, credentials and sessions.
// No identity capability is enabled in the foundation release.
package identity

@ -0,0 +1,2 @@
// Package moderation will own reports, restrictions and appeals.
package moderation

@ -0,0 +1,3 @@
// Package rooms will own rooms, memberships and invitations.
// Room commands will share a transaction with the game engine.
package rooms

@ -0,0 +1,3 @@
// Package safety will decide age, supervision and communication capabilities.
// It will be the shared authority for HTTP, realtime delivery and jobs.
package safety

@ -0,0 +1,3 @@
// Package engine will execute versioned, deterministic game rules.
// The TypeScript contract in docs is a semantic reference, not runnable Go.
package engine

@ -0,0 +1,3 @@
// Package games will register internally published Go rule modules.
// No game is installed in the foundation release.
package games

@ -0,0 +1,3 @@
// Package postgres will implement repositories using one shared transaction.
// PostgreSQL is not connected in the foundation release.
package postgres

@ -0,0 +1,3 @@
// Package providers will adapt email, age assurance and SMS providers.
// No external provider is configured in the foundation release.
package providers

@ -0,0 +1,3 @@
// Package jobs will drive durable deadlines and outbox delivery through
// the same application services used by the API.
package jobs

@ -0,0 +1,50 @@
package server
import (
"time"
"juegoland/games2/internal/domain/catalog"
)
type CatalogReader interface {
Published() []catalog.Game
}
type Service struct {
catalog CatalogReader
now func() time.Time
}
type Features struct {
Catalog bool `json:"catalog"`
Rooms bool `json:"rooms"`
Chat bool `json:"chat"`
}
type Bootstrap struct {
ServerTime time.Time `json:"serverTime"`
PlatformProtocolVersions []int `json:"platformProtocolVersions"`
GameProtocolVersions []int `json:"gameProtocolVersions"`
Features Features `json:"features"`
}
func NewService(reader CatalogReader, now func() time.Time) *Service {
return &Service{catalog: reader, now: now}
}
func (s *Service) Bootstrap() Bootstrap {
return Bootstrap{
ServerTime: s.now().UTC(),
PlatformProtocolVersions: []int{},
GameProtocolVersions: []int{},
Features: Features{
Catalog: true,
Rooms: false,
Chat: false,
},
}
}
func (s *Service) PublishedGames() []catalog.Game {
return s.catalog.Published()
}

59
package-lock.json generated

@ -0,0 +1,59 @@
{
"name": "games2-contracts",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "games2-contracts",
"version": "0.1.0",
"devDependencies": {
"@types/node": "24.19.1",
"typescript": "6.0.3",
"zod": "4.6.5"
},
"engines": {
"node": ">=24.0.0"
}
},
"node_modules/@types/node": {
"version": "24.19.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz",
"integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/zod": {
"version": "4.6.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

@ -0,0 +1,17 @@
{
"name": "games2-contracts",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"contracts:check": "node docs/game-engine/verify.ts && tsc --project docs/game-engine/tsconfig.json"
},
"devDependencies": {
"@types/node": "24.19.1",
"typescript": "6.0.3",
"zod": "4.6.5"
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.