diff --git a/README.md b/README.md index 6f1160d..fce4465 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 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. +Para continuar el trabajo en otro proyecto de Codex, leer el [handoff del 6 de octubre de 2026](docs/HANDOFF-2026-10-06.md) antes de cambiar el árbol de trabajo. + **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 @@ -29,7 +31,7 @@ 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: +Las herramientas para validar contratos del motor y modelos compartidos tienen dependencias propias en la raíz del proyecto: ```powershell npm ci @@ -69,4 +71,14 @@ 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. +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), la [política de idiomas](docs/platform/localization.md), el [perfil multidispositivo por juego](docs/game-engine/multi-device-profile.md), el [objetivo de capacidad](docs/platform/capacity-and-scaling.md) y el [plan de implementación](docs/ROADMAP.md) indican los límites de esta primera fase. La [exploración visual](docs/platform/visual-design.md) no está aprobada; la identidad y los tokens quedan por definir. + +La portada inicial es un prototipo visual no aprobado con selector de español/inglés. El catálogo envía `Accept-Language`; el servidor responde con `Content-Language` y marca el idioma real de cada ficha. Los juegos publicados, sus reglas y recursos aún deberán cumplir la política de traducción antes de aparecer en el catálogo. + +El [perfil propuesto de jugadores virtuales](docs/game-engine/virtual-player-profile.md) permite preparar plazas controladas por agentes IA: cada juego debe declarar esa opción en sus propiedades e incluir las instrucciones versionadas del agente. El motor conserva la validación de reglas y el servidor limita el agente a la información de su asiento. No hay jugadores IA implementados ni soporte de este perfil en los esquemas v1 actuales. + +El [perfil multidispositivo](docs/game-engine/multi-device-profile.md) propone `TVBoard` (TV pública + mando móvil), `Desktop` y `Mobil` (estado autorizado + controles). Distingue mesas por QR sin invitaciones y TV personal tras invitación, con cambio a móvil completo. El [perfil de sincronización](docs/game-engine/synchronization-profile.md) recoge técnicas de la industria y propone reloj/rondas, acierto en ventana y compensación experimental con empates; aún sin runtime ni esquemas v2 ejecutables. + +El [perfil común de comunicación](docs/platform/client-communication-profile.md) prepara mandos web y una app Flutter posterior **solo de controles**, con las mismas acciones y proyección `player-controller`. Tableros y vistas completas se representan en la web; el paso a móvil completo desde Flutter abre `Mobil` web conservando jugador y recibos. Propone HTTPS/WSS, SDK TypeScript/Dart, acceso y controles por runtime, QR y recuperación; sigue siendo diseño, sin app nativa ni binding nuevo implementados. + +Los [modelos compartidos](docs/platform/shared-data-models.md) tienen fuente JSON Schema y DTOs generados para TypeScript, Go y Dart bajo `contracts/shared/`. Incluyen un núcleo común de error, mensajes, correlación y catálogo. `npm run contracts:generate` actualiza tipos; `contracts:check` comprueba deriva, fixtures y relaciones. Go/Dart verifican lectura y serialización del mismo JSON. Los validadores/adaptadores de errores de red se incorporan en los siguientes cortes. diff --git a/contracts/README.md b/contracts/README.md index 92c55b0..e8280a0 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -2,4 +2,6 @@ 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. +Los [modelos compartidos](../docs/platform/shared-data-models.md) tienen fuente en [shared/schema.json](shared/schema.json): errores, mensajes, correlación, primitivas y catálogo. `npm run contracts:generate` genera DTOs TypeScript, Go y Dart; `npm run contracts:check` comprueba su correspondencia y los fixtures de estructura/semántica. Go/Dart tienen comprobaciones de lectura y serialización del mismo JSON; no equivalen todavía a validadores completos de recepción. + +OpenAPI de plataforma, sobres v2 completos, validadores de red e interfaces Go equivalentes al SDK de reglas siguen pendientes. Estos modelos no constituyen un contrato completo de cuentas, salas o chat ni una app Flutter. diff --git a/contracts/shared/dart/models.g.dart b/contracts/shared/dart/models.g.dart new file mode 100644 index 0000000..eda2e7f --- /dev/null +++ b/contracts/shared/dart/models.g.dart @@ -0,0 +1,239 @@ +// Generated from ../schema.json. Do not edit. +// Structural DTOs; schema and semantic validation are separate. + +typedef UUID = String; + +typedef Revision = int; + +typedef Instant = String; + +typedef Locale = String; + +typedef ErrorCode = String; + +typedef MessageArgument = Object; + +typedef MessageArgs = Map; + +class LocalizedMessage { + final String key; + final MessageArgs args; + const LocalizedMessage({required this.key, required this.args}); + factory LocalizedMessage.fromJson(Map json) => LocalizedMessage( + key: _required(json, 'key') as String, + args: _object(_required(json, 'args')).map((key, item) => MapEntry(key, _argument(item))), + ); + Map toJson() => { + 'key': key, + 'args': args, + }; +} + +typedef ErrorOrigin = String; + +typedef ErrorCategory = String; + +typedef OperationOutcome = String; + +typedef RecoveryAction = String; + +class Recovery { + final RecoveryAction action; + final int? retryAfterMs; + const Recovery({required this.action, this.retryAfterMs}); + factory Recovery.fromJson(Map json) => Recovery( + action: _required(json, 'action') as String, + retryAfterMs: json['retryAfterMs'] == null ? null : json['retryAfterMs'] as int, + ); + Map toJson() => { + 'action': action, + if (retryAfterMs != null) 'retryAfterMs': retryAfterMs, + }; +} + +class Violation { + final String pointer; + final ErrorCode code; + final LocalizedMessage message; + const Violation({required this.pointer, required this.code, required this.message}); + factory Violation.fromJson(Map json) => Violation( + pointer: _required(json, 'pointer') as String, + code: _required(json, 'code') as String, + message: LocalizedMessage.fromJson(_object(_required(json, 'message'))), + ); + Map toJson() => { + 'pointer': pointer, + 'code': code, + 'message': message.toJson(), + }; +} + +class ErrorData { + final int modelVersion; + final ErrorOrigin origin; + final ErrorCode code; + final ErrorCategory category; + final OperationOutcome outcome; + final LocalizedMessage message; + final Recovery recovery; + final List violations; + const ErrorData({required this.modelVersion, required this.origin, required this.code, required this.category, required this.outcome, required this.message, required this.recovery, required this.violations}); + factory ErrorData.fromJson(Map json) => ErrorData( + modelVersion: _required(json, 'modelVersion') as int, + origin: _required(json, 'origin') as String, + code: _required(json, 'code') as String, + category: _required(json, 'category') as String, + outcome: _required(json, 'outcome') as String, + message: LocalizedMessage.fromJson(_object(_required(json, 'message'))), + recovery: Recovery.fromJson(_object(_required(json, 'recovery'))), + violations: (_required(json, 'violations') as List).map((item) => Violation.fromJson(_object(item))).toList(), + ); + Map toJson() => { + 'modelVersion': modelVersion, + 'origin': origin, + 'code': code, + 'category': category, + 'outcome': outcome, + 'message': message.toJson(), + 'recovery': recovery.toJson(), + 'violations': violations.map((item) => item.toJson()).toList(), + }; +} + +class Correlation { + final UUID? requestId; + final UUID? commandId; + final UUID? operationId; + final UUID? subscriptionId; + const Correlation({this.requestId, this.commandId, this.operationId, this.subscriptionId}); + factory Correlation.fromJson(Map json) => Correlation( + requestId: json['requestId'] == null ? null : json['requestId'] as String, + commandId: json['commandId'] == null ? null : json['commandId'] as String, + operationId: json['operationId'] == null ? null : json['operationId'] as String, + subscriptionId: json['subscriptionId'] == null ? null : json['subscriptionId'] as String, + ); + Map toJson() => { + if (requestId != null) 'requestId': requestId, + if (commandId != null) 'commandId': commandId, + if (operationId != null) 'operationId': operationId, + if (subscriptionId != null) 'subscriptionId': subscriptionId, + }; +} + +class HttpProblem { + final String type; + final String title; + final int status; + final Correlation correlation; + final ErrorData error; + const HttpProblem({required this.type, required this.title, required this.status, required this.correlation, required this.error}); + factory HttpProblem.fromJson(Map json) => HttpProblem( + type: _required(json, 'type') as String, + title: _required(json, 'title') as String, + status: _required(json, 'status') as int, + correlation: Correlation.fromJson(_object(_required(json, 'correlation'))), + error: ErrorData.fromJson(_object(_required(json, 'error'))), + ); + Map toJson() => { + 'type': type, + 'title': title, + 'status': status, + 'correlation': correlation.toJson(), + 'error': error.toJson(), + }; +} + +class ErrorMessagePayload { + final Correlation correlation; + final ErrorData error; + const ErrorMessagePayload({required this.correlation, required this.error}); + factory ErrorMessagePayload.fromJson(Map json) => ErrorMessagePayload( + correlation: Correlation.fromJson(_object(_required(json, 'correlation'))), + error: ErrorData.fromJson(_object(_required(json, 'error'))), + ); + Map toJson() => { + 'correlation': correlation.toJson(), + 'error': error.toJson(), + }; +} + +class CommandRejection { + final String status; + final ErrorData error; + const CommandRejection({required this.status, required this.error}); + factory CommandRejection.fromJson(Map json) => CommandRejection( + status: _required(json, 'status') as String, + error: ErrorData.fromJson(_object(_required(json, 'error'))), + ); + Map toJson() => { + 'status': status, + 'error': error.toJson(), + }; +} + +class GameCard { + final String id; + final String version; + final String title; + final String summary; + final Locale locale; + final int minPlayers; + final int maxPlayers; + const GameCard({required this.id, required this.version, required this.title, required this.summary, required this.locale, required this.minPlayers, required this.maxPlayers}); + factory GameCard.fromJson(Map json) => GameCard( + id: _required(json, 'id') as String, + version: _required(json, 'version') as String, + title: _required(json, 'title') as String, + summary: _required(json, 'summary') as String, + locale: _required(json, 'locale') as String, + minPlayers: _required(json, 'minPlayers') as int, + maxPlayers: _required(json, 'maxPlayers') as int, + ); + Map toJson() => { + 'id': id, + 'version': version, + 'title': title, + 'summary': summary, + 'locale': locale, + 'minPlayers': minPlayers, + 'maxPlayers': maxPlayers, + }; +} + +class CatalogPage { + final List items; + final String? nextCursor; + const CatalogPage({required this.items, required this.nextCursor}); + factory CatalogPage.fromJson(Map json) => CatalogPage( + items: (_required(json, 'items') as List).map((item) => GameCard.fromJson(_object(item))).toList(), + nextCursor: _required(json, 'nextCursor') == null ? null : _required(json, 'nextCursor') as String, + ); + Map toJson() => { + 'items': items.map((item) => item.toJson()).toList(), + 'nextCursor': nextCursor, + }; +} + +class CatalogResponse { + final UUID requestId; + final CatalogPage data; + const CatalogResponse({required this.requestId, required this.data}); + factory CatalogResponse.fromJson(Map json) => CatalogResponse( + requestId: _required(json, 'requestId') as String, + data: CatalogPage.fromJson(_object(_required(json, 'data'))), + ); + Map toJson() => { + 'requestId': requestId, + 'data': data.toJson(), + }; +} + +dynamic _required(Map json, String key) { + if (!json.containsKey(key)) throw FormatException('Missing field: $key'); + return json[key]; +} +Map _object(dynamic value) => Map.from(value as Map); +Object _argument(dynamic value) { + if (value is String || value is bool || (value is num && value.isFinite)) return value as Object; + throw const FormatException('Invalid message argument'); +} diff --git a/contracts/shared/dart/verify.dart b/contracts/shared/dart/verify.dart new file mode 100644 index 0000000..7cd6853 --- /dev/null +++ b/contracts/shared/dart/verify.dart @@ -0,0 +1,36 @@ +import 'dart:convert'; +import 'dart:io'; +import 'models.g.dart'; + +void main() { + final fixtures = jsonDecode(File.fromUri(Platform.script.resolve('../fixtures.json')).readAsStringSync()) as Map; + final cases = fixtures['valid'] as List; + for (final fixture in cases) { + final value = fixture['value']; + final decoded = switch (fixture['model']) { + 'UUID' || 'Instant' || 'Locale' => value as String, + 'Revision' => value as int, + 'LocalizedMessage' => LocalizedMessage.fromJson(value).toJson(), + 'ErrorData' => ErrorData.fromJson(value).toJson(), + 'HttpProblem' => HttpProblem.fromJson(value).toJson(), + 'ErrorMessagePayload' => ErrorMessagePayload.fromJson(value).toJson(), + 'CommandRejection' => CommandRejection.fromJson(value).toJson(), + 'CatalogPage' => CatalogPage.fromJson(value).toJson(), + 'CatalogResponse' => CatalogResponse.fromJson(value).toJson(), + _ => throw StateError('Missing fixture decoder: ${fixture['model']}'), + }; + if (jsonEncode(_canonical(value)) != jsonEncode(_canonical(decoded))) { + throw StateError('JSON changed: ${fixture['name']}'); + } + } + print('${cases.length} shared Dart JSON round trips verified.'); +} + +dynamic _canonical(dynamic value) { + if (value is List) return value.map(_canonical).toList(); + if (value is Map) { + final keys = value.keys.cast().toList()..sort(); + return {for (final key in keys) key: _canonical(value[key])}; + } + return value; +} diff --git a/contracts/shared/fixtures.json b/contracts/shared/fixtures.json new file mode 100644 index 0000000..82a9457 --- /dev/null +++ b/contracts/shared/fixtures.json @@ -0,0 +1,693 @@ +{ + "valid": [ + { + "name": "uuid", + "model": "UUID", + "value": "f8aa2fa1-aec8-41c8-a27b-644e999df03a" + }, + { + "name": "max-safe-revision", + "model": "Revision", + "value": 9007199254740991 + }, + { + "name": "instant", + "model": "Instant", + "value": "2026-10-06T16:00:00Z" + }, + { + "name": "locale-with-script", + "model": "Locale", + "value": "zh-Hant-TW" + }, + { + "name": "localized-arguments", + "model": "LocalizedMessage", + "value": { + "key": "errors.limitReached", + "args": { + "limit": 4, + "label": "Máximo", + "available": false + } + } + }, + { + "name": "room-full", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": {} + }, + "recovery": { + "action": "change-selection" + }, + "violations": [] + } + }, + { + "name": "validation", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "server", + "code": "INVALID_REQUEST", + "category": "validation", + "outcome": "not-applied", + "message": { + "key": "errors.invalidRequest", + "args": {} + }, + "recovery": { + "action": "correct-input" + }, + "violations": [ + { + "pointer": "/config/capacity", + "code": "OUT_OF_RANGE", + "message": { + "key": "errors.outOfRange", + "args": { + "min": 1, + "max": 4 + } + } + } + ] + } + }, + { + "name": "rate-limit", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "server", + "code": "RATE_LIMITED", + "category": "rate-limit", + "outcome": "not-applied", + "message": { + "key": "errors.rateLimited", + "args": {} + }, + "recovery": { + "action": "retry-same-request", + "retryAfterMs": 1250 + }, + "violations": [] + } + }, + { + "name": "http-room-full", + "model": "HttpProblem", + "value": { + "type": "urn:juegoland:problem:room-full", + "title": "La sala está completa", + "status": 409, + "correlation": { + "requestId": "f8aa2fa1-aec8-41c8-a27b-644e999df03a", + "commandId": "f8aa2fa1-aec8-41c8-a27b-644e999df03a" + }, + "error": { + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": {} + }, + "recovery": { + "action": "change-selection" + }, + "violations": [] + } + } + }, + { + "name": "socket-room-full", + "model": "ErrorMessagePayload", + "value": { + "correlation": { + "requestId": "f8aa2fa1-aec8-41c8-a27b-644e999df03a", + "commandId": "f8aa2fa1-aec8-41c8-a27b-644e999df03a" + }, + "error": { + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": {} + }, + "recovery": { + "action": "change-selection" + }, + "violations": [] + } + } + }, + { + "name": "receipt-room-full", + "model": "CommandRejection", + "value": { + "status": "rejected", + "error": { + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": {} + }, + "recovery": { + "action": "change-selection" + }, + "violations": [] + } + } + }, + { + "name": "lost-command", + "model": "ErrorMessagePayload", + "value": { + "correlation": { + "commandId": "f8aa2fa1-aec8-41c8-a27b-644e999df03a" + }, + "error": { + "modelVersion": 1, + "origin": "transport", + "code": "CONNECTION_LOST", + "category": "transport", + "outcome": "unknown", + "message": { + "key": "errors.connectionLost", + "args": {} + }, + "recovery": { + "action": "resolve-pending" + }, + "violations": [] + } + } + }, + { + "name": "catalog-empty", + "model": "CatalogPage", + "value": { + "items": [], + "nextCursor": null + } + }, + { + "name": "catalog-page", + "model": "CatalogResponse", + "value": { + "requestId": "f8aa2fa1-aec8-41c8-a27b-644e999df03a", + "data": { + "items": [ + { + "id": "conecta4", + "version": "1.0.0", + "title": "Conecta 4", + "summary": "Cuatro en línea", + "locale": "es", + "minPlayers": 2, + "maxPlayers": 2 + } + ], + "nextCursor": "opaque-cursor" + } + } + } + ], + "invalid": [ + { + "name": "unknown-model-version", + "model": "ErrorData", + "value": { + "modelVersion": 2, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": {} + }, + "recovery": { + "action": "change-selection" + }, + "violations": [] + } + }, + { + "name": "missing-recovery", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": {} + }, + "violations": [] + } + }, + { + "name": "null-violations", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": {} + }, + "recovery": { + "action": "change-selection" + }, + "violations": null + } + }, + { + "name": "numeric-code", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "server", + "code": 3, + "category": "conflict", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": {} + }, + "recovery": { + "action": "change-selection" + }, + "violations": [] + } + }, + { + "name": "unknown-category", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "sql", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": {} + }, + "recovery": { + "action": "change-selection" + }, + "violations": [] + } + }, + { + "name": "rejected-auto-retry", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": {} + }, + "recovery": { + "action": "retry-same-request" + }, + "violations": [] + } + }, + { + "name": "unknown-auto-retry", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "transport", + "code": "CONNECTION_LOST", + "category": "transport", + "outcome": "unknown", + "message": { + "key": "errors.connectionLost", + "args": {} + }, + "recovery": { + "action": "retry-same-request" + }, + "violations": [] + } + }, + { + "name": "negative-retry", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "server", + "code": "RATE_LIMITED", + "category": "rate-limit", + "outcome": "not-applied", + "message": { + "key": "errors.rateLimited", + "args": {} + }, + "recovery": { + "action": "retry-same-request", + "retryAfterMs": -1 + }, + "violations": [] + } + }, + { + "name": "fractional-retry", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "server", + "code": "RATE_LIMITED", + "category": "rate-limit", + "outcome": "not-applied", + "message": { + "key": "errors.rateLimited", + "args": {} + }, + "recovery": { + "action": "retry-same-request", + "retryAfterMs": 0.5 + }, + "violations": [] + } + }, + { + "name": "retry-with-change-selection", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": {} + }, + "recovery": { + "action": "change-selection", + "retryAfterMs": 12 + }, + "violations": [] + } + }, + { + "name": "nested-message-argument", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": { + "secret": { + "token": "must-not-be-accepted" + } + } + }, + "recovery": { + "action": "change-selection" + }, + "violations": [] + } + }, + { + "name": "too-many-message-args", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": { + "arg0": 0, + "arg1": 1, + "arg2": 2, + "arg3": 3, + "arg4": 4, + "arg5": 5, + "arg6": 6, + "arg7": 7, + "arg8": 8, + "arg9": 9, + "arg10": 10, + "arg11": 11, + "arg12": 12 + } + }, + "recovery": { + "action": "change-selection" + }, + "violations": [] + } + }, + { + "name": "invalid-arg-name", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": { + "bad-key": 2 + } + }, + "recovery": { + "action": "change-selection" + }, + "violations": [] + } + }, + { + "name": "overlong-argument", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": { + "label": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + }, + "recovery": { + "action": "change-selection" + }, + "violations": [] + } + }, + { + "name": "invalid-json-pointer", + "model": "ErrorData", + "value": { + "modelVersion": 1, + "origin": "server", + "code": "INVALID_REQUEST", + "category": "validation", + "outcome": "not-applied", + "message": { + "key": "errors.invalidRequest", + "args": {} + }, + "recovery": { + "action": "correct-input" + }, + "violations": [ + { + "pointer": "/bad~escape", + "code": "OUT_OF_RANGE", + "message": { + "key": "errors.outOfRange", + "args": { + "min": 1, + "max": 4 + } + } + } + ] + } + }, + { + "name": "problem-type-code", + "model": "HttpProblem", + "value": { + "type": "urn:juegoland:problem:other", + "title": "La sala está completa", + "status": 409, + "correlation": { + "requestId": "f8aa2fa1-aec8-41c8-a27b-644e999df03a", + "commandId": "f8aa2fa1-aec8-41c8-a27b-644e999df03a" + }, + "error": { + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": {} + }, + "recovery": { + "action": "change-selection" + }, + "violations": [] + } + } + }, + { + "name": "client-http-problem", + "model": "HttpProblem", + "value": { + "type": "urn:juegoland:problem:room-full", + "title": "La sala está completa", + "status": 409, + "correlation": { + "requestId": "f8aa2fa1-aec8-41c8-a27b-644e999df03a", + "commandId": "f8aa2fa1-aec8-41c8-a27b-644e999df03a" + }, + "error": { + "modelVersion": 1, + "origin": "client", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { + "key": "errors.roomFull", + "args": {} + }, + "recovery": { + "action": "change-selection" + }, + "violations": [] + } + } + }, + { + "name": "unknown-without-reference", + "model": "ErrorMessagePayload", + "value": { + "correlation": {}, + "error": { + "modelVersion": 1, + "origin": "transport", + "code": "CONNECTION_LOST", + "category": "transport", + "outcome": "unknown", + "message": { + "key": "errors.connectionLost", + "args": {} + }, + "recovery": { + "action": "resolve-pending" + }, + "violations": [] + } + } + }, + { + "name": "unknown-rejection", + "model": "CommandRejection", + "value": { + "status": "rejected", + "error": { + "modelVersion": 1, + "origin": "transport", + "code": "CONNECTION_LOST", + "category": "transport", + "outcome": "unknown", + "message": { + "key": "errors.connectionLost", + "args": {} + }, + "recovery": { + "action": "resolve-pending" + }, + "violations": [] + } + } + }, + { + "name": "unsafe-revision", + "model": "Revision", + "value": 9007199254740992 + }, + { + "name": "fractional-revision", + "model": "Revision", + "value": 1.5 + }, + { + "name": "reversed-player-limits", + "model": "CatalogResponse", + "value": { + "requestId": "f8aa2fa1-aec8-41c8-a27b-644e999df03a", + "data": { + "items": [ + { + "id": "conecta4", + "version": "1.0.0", + "title": "Conecta 4", + "summary": "Cuatro en línea", + "locale": "es", + "minPlayers": 3, + "maxPlayers": 2 + } + ], + "nextCursor": "opaque-cursor" + } + } + }, + { + "name": "missing-null-cursor", + "model": "CatalogPage", + "value": { + "items": [] + } + } + ] +} diff --git a/contracts/shared/generate.ts b/contracts/shared/generate.ts new file mode 100644 index 0000000..91d1fa4 --- /dev/null +++ b/contracts/shared/generate.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; + +type Schema = { + $ref?: string; type?: string | string[]; const?: string | number; + enum?: string[]; properties?: Record; required?: string[]; + additionalProperties?: boolean | Schema; items?: Schema; +}; +const root = JSON.parse(await readFile(new URL('schema.json', import.meta.url), 'utf8')) as { $defs: Record }; +const defs = root.$defs; +const ref = (s: Schema) => s.$ref?.split('/').at(-1); +const namedObject = (s: Schema) => ref(s) && !!defs[ref(s)!]?.properties; + +function type(s: Schema, lang: 'ts' | 'go' | 'dart'): string { + if (s.$ref) return ref(s)!; + if (Array.isArray(s.type)) { + if (s.type.includes('null')) { + const inner = type({ ...s, type: s.type.filter(v => v !== 'null')[0] }, lang); + return lang === 'ts' ? `${inner} | null` : lang === 'go' ? `*${inner}` : `${inner}?`; + } + return lang === 'ts' ? s.type.map(t => type({ type: t }, lang)).join(' | ') : lang === 'go' ? 'any' : 'Object'; + } + if (lang === 'ts' && s.const !== undefined) return JSON.stringify(s.const); + if (lang === 'ts' && s.enum) return s.enum.map(v => JSON.stringify(v)).join(' | '); + switch (s.type) { + case 'string': return lang === 'dart' ? 'String' : 'string'; + case 'integer': return lang === 'go' ? 'int64' : lang === 'dart' ? 'int' : 'number'; + case 'number': return lang === 'go' ? 'float64' : lang === 'dart' ? 'num' : 'number'; + case 'boolean': return lang === 'ts' ? 'boolean' : 'bool'; + case 'array': return lang === 'go' ? `[]${type(s.items!, lang)}` : lang === 'dart' ? `List<${type(s.items!, lang)}>` : `Array<${type(s.items!, lang)}>`; + case 'object': + assert.ok(s.additionalProperties && typeof s.additionalProperties === 'object', 'Unnamed object needs typed additionalProperties'); + return lang === 'go' ? `map[string]${type(s.additionalProperties, lang)}` : lang === 'dart' ? `Map` : `Record`; + default: throw new Error(`Unsupported schema type ${s.type}`); + } +} +function goName(name: string) { return name.replace(/^id$/, 'ID').replace(/Id$/, 'ID').replace(/^./, c => c.toUpperCase()); } +function dartDecode(s: Schema, value: string): string { + if (Array.isArray(s.type) && s.type.includes('null')) return `${value} == null ? null : ${dartDecode({ ...s, type: s.type.find(t => t !== 'null') }, value)}`; + if (s.$ref) return namedObject(s) ? `${ref(s)}.fromJson(_object(${value}))` : dartDecode(defs[ref(s)!]!, value); + if (Array.isArray(s.type)) return `_argument(${value})`; + if (s.type === 'array') return `(${value} as List).map((item) => ${dartDecode(s.items!, 'item')}).toList()`; + if (s.type === 'object') return `_object(${value}).map((key, item) => MapEntry(key, ${dartDecode(s.additionalProperties as Schema, 'item')}))`; + return `${value} as ${type(s, 'dart')}`; +} +function dartEncode(s: Schema, value: string): string { + if (namedObject(s)) return `${value}.toJson()`; + if (s.type === 'array' && namedObject(s.items!)) return `${value}.map((item) => item.toJson()).toList()`; + return value; +} +let ts = '// Generated from ../schema.json. Edit the schema and run npm run contracts:generate.\n\n'; +let go = '// Code generated from ../schema.json; DO NOT EDIT.\npackage shared\n\n'; +let dart = '// Generated from ../schema.json. Do not edit.\n// Structural DTOs; schema and semantic validation are separate.\n\n'; +for (const [name, schema] of Object.entries(defs)) { + if (!schema.properties) { + ts += `export type ${name} = ${type(schema, 'ts')};\n\n`; + go += `type ${name} ${type(schema, 'go')}\n\n`; + dart += `typedef ${name} = ${type(schema, 'dart')};\n\n`; + continue; + } + const fields = Object.entries(schema.properties); + const required = new Set(schema.required); + ts += `export interface ${name} {\n${fields.map(([key,s]) => ` ${key}${required.has(key) ? '' : '?'}: ${type(s,'ts')};`).join('\n')}\n}\n\n`; + go += `type ${name} struct {\n${fields.map(([key,s]) => `\t${goName(key)} ${required.has(key) ? '' : '*'}${type(s,'go')} \x60json:"${key}${required.has(key) ? '' : ',omitempty'}"\x60`).join('\n')}\n}\n\n`; + dart += `class ${name} {\n${fields.map(([key,s]) => ` final ${type(s,'dart')}${required.has(key) ? '' : '?'} ${key};`).join('\n')}\n`; + dart += ` const ${name}({${fields.map(([key]) => `${required.has(key) ? 'required ' : ''}this.${key}`).join(', ')}});\n`; + dart += ` factory ${name}.fromJson(Map json) => ${name}(\n${fields.map(([key,s]) => ` ${key}: ${required.has(key) ? dartDecode(s, `_required(json, '${key}')`) : `json['${key}'] == null ? null : ${dartDecode(s, `json['${key}']`)}`},`).join('\n')}\n );\n`; + dart += ` Map toJson() => {\n${fields.map(([key,s]) => ` ${required.has(key) ? '' : `if (${key} != null) `}'${key}': ${dartEncode(s,key)},`).join('\n')}\n };\n}\n\n`; +} +dart += `dynamic _required(Map json, String key) {\n if (!json.containsKey(key)) throw FormatException('Missing field: $key');\n return json[key];\n}\nMap _object(dynamic value) => Map.from(value as Map);\nObject _argument(dynamic value) {\n if (value is String || value is bool || (value is num && value.isFinite)) return value as Object;\n throw const FormatException('Invalid message argument');\n}\n`; +go = execFileSync('gofmt', { input: go, encoding: 'utf8' }); +const outputs = { 'ts/models.generated.ts': ts.trimEnd() + '\n', 'go/models.gen.go': go.trimEnd() + '\n', 'dart/models.g.dart': dart.trimEnd() + '\n' }; +if (process.argv.includes('--print')) { + console.log(JSON.stringify(outputs)); +} else { +for (const [file, value] of Object.entries(outputs)) { + const url = new URL(file, import.meta.url); + if (process.argv.includes('--write')) { + await mkdir(new URL('.', url), { recursive: true }); + await writeFile(url, value); + } + assert.equal(await readFile(url, 'utf8'), value, `Generated DTO drift: ${file}`); +} +console.log(`${Object.keys(defs).length} shared models: TypeScript, Go and Dart bindings match schema.json.`); +} diff --git a/contracts/shared/go/models.gen.go b/contracts/shared/go/models.gen.go new file mode 100644 index 0000000..fc651fa --- /dev/null +++ b/contracts/shared/go/models.gen.go @@ -0,0 +1,96 @@ +// Code generated from ../schema.json; DO NOT EDIT. +package shared + +type UUID string + +type Revision int64 + +type Instant string + +type Locale string + +type ErrorCode string + +type MessageArgument any + +type MessageArgs map[string]MessageArgument + +type LocalizedMessage struct { + Key string `json:"key"` + Args MessageArgs `json:"args"` +} + +type ErrorOrigin string + +type ErrorCategory string + +type OperationOutcome string + +type RecoveryAction string + +type Recovery struct { + Action RecoveryAction `json:"action"` + RetryAfterMs *int64 `json:"retryAfterMs,omitempty"` +} + +type Violation struct { + Pointer string `json:"pointer"` + Code ErrorCode `json:"code"` + Message LocalizedMessage `json:"message"` +} + +type ErrorData struct { + ModelVersion int64 `json:"modelVersion"` + Origin ErrorOrigin `json:"origin"` + Code ErrorCode `json:"code"` + Category ErrorCategory `json:"category"` + Outcome OperationOutcome `json:"outcome"` + Message LocalizedMessage `json:"message"` + Recovery Recovery `json:"recovery"` + Violations []Violation `json:"violations"` +} + +type Correlation struct { + RequestID *UUID `json:"requestId,omitempty"` + CommandID *UUID `json:"commandId,omitempty"` + OperationID *UUID `json:"operationId,omitempty"` + SubscriptionID *UUID `json:"subscriptionId,omitempty"` +} + +type HttpProblem struct { + Type string `json:"type"` + Title string `json:"title"` + Status int64 `json:"status"` + Correlation Correlation `json:"correlation"` + Error ErrorData `json:"error"` +} + +type ErrorMessagePayload struct { + Correlation Correlation `json:"correlation"` + Error ErrorData `json:"error"` +} + +type CommandRejection struct { + Status string `json:"status"` + Error ErrorData `json:"error"` +} + +type GameCard struct { + ID string `json:"id"` + Version string `json:"version"` + Title string `json:"title"` + Summary string `json:"summary"` + Locale Locale `json:"locale"` + MinPlayers int64 `json:"minPlayers"` + MaxPlayers int64 `json:"maxPlayers"` +} + +type CatalogPage struct { + Items []GameCard `json:"items"` + NextCursor *string `json:"nextCursor"` +} + +type CatalogResponse struct { + RequestID UUID `json:"requestId"` + Data CatalogPage `json:"data"` +} diff --git a/contracts/shared/go/models_test.go b/contracts/shared/go/models_test.go new file mode 100644 index 0000000..4b7361d --- /dev/null +++ b/contracts/shared/go/models_test.go @@ -0,0 +1,38 @@ +package shared + +import ( + "encoding/json" + "os" + "reflect" + "testing" +) + +func TestSharedGoldenJSON(t *testing.T) { + data, err := os.ReadFile("../fixtures.json") + if err != nil { t.Fatal(err) } + var fixtures struct { Valid []struct { Name, Model string; Value json.RawMessage } } + if err := json.Unmarshal(data, &fixtures); err != nil { t.Fatal(err) } + constructors := map[string]func() any{ + "UUID": func() any { return new(UUID) }, "Revision": func() any { return new(Revision) }, + "Instant": func() any { return new(Instant) }, "Locale": func() any { return new(Locale) }, + "LocalizedMessage": func() any { return new(LocalizedMessage) }, + "ErrorData": func() any { return new(ErrorData) }, "HttpProblem": func() any { return new(HttpProblem) }, + "ErrorMessagePayload": func() any { return new(ErrorMessagePayload) }, + "CommandRejection": func() any { return new(CommandRejection) }, + "CatalogPage": func() any { return new(CatalogPage) }, "CatalogResponse": func() any { return new(CatalogResponse) }, + } + for _, fixture := range fixtures.Valid { + t.Run(fixture.Name, func(t *testing.T) { + constructor, ok := constructors[fixture.Model] + if !ok { t.Fatalf("missing fixture decoder for %s", fixture.Model) } + model := constructor() + if err := json.Unmarshal(fixture.Value, model); err != nil { t.Fatal(err) } + encoded, err := json.Marshal(model) + if err != nil { t.Fatal(err) } + var before, after any + if err := json.Unmarshal(fixture.Value, &before); err != nil { t.Fatal(err) } + if err := json.Unmarshal(encoded, &after); err != nil { t.Fatal(err) } + if !reflect.DeepEqual(before, after) { t.Fatalf("JSON changed: before=%s after=%s", fixture.Value, encoded) } + }) + } +} diff --git a/contracts/shared/schema.json b/contracts/shared/schema.json new file mode 100644 index 0000000..912f690 --- /dev/null +++ b/contracts/shared/schema.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:juegoland:shared-models:1", + "$ref": "#/$defs/ErrorData", + "$defs": { + "UUID": { "type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" }, + "Revision": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, + "Instant": { "type": "string", "format": "date-time", "pattern": "Z$" }, + "Locale": { "type": "string", "minLength": 2, "maxLength": 64, "pattern": "^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$" }, + "ErrorCode": { "type": "string", "minLength": 2, "maxLength": 64, "pattern": "^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$" }, + "MessageArgument": { "type": ["string", "number", "boolean"], "maxLength": 256, "minimum": -9007199254740991, "maximum": 9007199254740991 }, + "MessageArgs": { + "type": "object", "maxProperties": 12, + "propertyNames": { "pattern": "^[a-zA-Z][a-zA-Z0-9_]{0,31}$" }, + "additionalProperties": { "$ref": "#/$defs/MessageArgument" } + }, + "LocalizedMessage": { + "type": "object", "additionalProperties": true, "required": ["key", "args"], + "properties": { + "key": { "type": "string", "minLength": 1, "maxLength": 160, "pattern": "^[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*$" }, + "args": { "$ref": "#/$defs/MessageArgs" } + } + }, + "ErrorOrigin": { "type": "string", "enum": ["server", "client", "transport"] }, + "ErrorCategory": { "type": "string", "enum": ["validation", "authentication", "authorization", "not-found", "conflict", "rate-limit", "availability", "internal", "protocol", "transport"] }, + "OperationOutcome": { "type": "string", "enum": ["not-applicable", "not-applied", "rejected", "unknown"] }, + "RecoveryAction": { "type": "string", "enum": ["none", "correct-input", "authenticate", "complete-requirements", "resync", "resolve-pending", "retry-same-request", "change-selection", "update-client", "contact-support"] }, + "Recovery": { + "type": "object", "additionalProperties": true, "required": ["action"], + "properties": { + "action": { "$ref": "#/$defs/RecoveryAction" }, + "retryAfterMs": { "type": "integer", "minimum": 0, "maximum": 86400000 } + } + }, + "Violation": { + "type": "object", "additionalProperties": true, "required": ["pointer", "code", "message"], + "properties": { + "pointer": { "type": "string", "maxLength": 256, "pattern": "^(?:/(?:[^~/]|~[01])*)*$" }, + "code": { "$ref": "#/$defs/ErrorCode" }, + "message": { "$ref": "#/$defs/LocalizedMessage" } + } + }, + "ErrorData": { + "type": "object", "additionalProperties": true, + "required": ["modelVersion", "origin", "code", "category", "outcome", "message", "recovery", "violations"], + "properties": { + "modelVersion": { "type": "integer", "const": 1 }, + "origin": { "$ref": "#/$defs/ErrorOrigin" }, + "code": { "$ref": "#/$defs/ErrorCode" }, + "category": { "$ref": "#/$defs/ErrorCategory" }, + "outcome": { "$ref": "#/$defs/OperationOutcome" }, + "message": { "$ref": "#/$defs/LocalizedMessage" }, + "recovery": { "$ref": "#/$defs/Recovery" }, + "violations": { "type": "array", "maxItems": 20, "items": { "$ref": "#/$defs/Violation" } } + } + }, + "Correlation": { + "type": "object", "additionalProperties": true, + "properties": { + "requestId": { "$ref": "#/$defs/UUID" }, + "commandId": { "$ref": "#/$defs/UUID" }, + "operationId": { "$ref": "#/$defs/UUID" }, + "subscriptionId": { "$ref": "#/$defs/UUID" } + } + }, + "HttpProblem": { + "type": "object", "additionalProperties": true, + "required": ["type", "title", "status", "correlation", "error"], + "properties": { + "type": { "type": "string", "pattern": "^urn:juegoland:problem:[a-z][a-z0-9-]{1,63}$" }, + "title": { "type": "string", "minLength": 1, "maxLength": 256 }, + "status": { "type": "integer", "minimum": 400, "maximum": 599 }, + "correlation": { "$ref": "#/$defs/Correlation" }, + "error": { "$ref": "#/$defs/ErrorData" } + } + }, + "ErrorMessagePayload": { + "type": "object", "additionalProperties": true, "required": ["correlation", "error"], + "properties": { + "correlation": { "$ref": "#/$defs/Correlation" }, + "error": { "$ref": "#/$defs/ErrorData" } + } + }, + "CommandRejection": { + "type": "object", "additionalProperties": true, "required": ["status", "error"], + "properties": { + "status": { "type": "string", "const": "rejected" }, + "error": { "$ref": "#/$defs/ErrorData" } + } + }, + "GameCard": { + "type": "object", "additionalProperties": true, + "required": ["id", "version", "title", "summary", "locale", "minPlayers", "maxPlayers"], + "properties": { + "id": { "type": "string", "minLength": 1, "maxLength": 64 }, + "version": { "type": "string", "minLength": 1, "maxLength": 64 }, + "title": { "type": "string", "minLength": 1, "maxLength": 256 }, + "summary": { "type": "string", "maxLength": 2048 }, + "locale": { "$ref": "#/$defs/Locale" }, + "minPlayers": { "type": "integer", "minimum": 1, "maximum": 64 }, + "maxPlayers": { "type": "integer", "minimum": 1, "maximum": 64 } + } + }, + "CatalogPage": { + "type": "object", "additionalProperties": true, "required": ["items", "nextCursor"], + "properties": { + "items": { "type": "array", "maxItems": 100, "items": { "$ref": "#/$defs/GameCard" } }, + "nextCursor": { "type": ["string", "null"], "minLength": 1, "maxLength": 512 } + } + }, + "CatalogResponse": { + "type": "object", "additionalProperties": true, "required": ["requestId", "data"], + "properties": { + "requestId": { "$ref": "#/$defs/UUID" }, + "data": { "$ref": "#/$defs/CatalogPage" } + } + } + } +} diff --git a/contracts/shared/ts/models.generated.ts b/contracts/shared/ts/models.generated.ts new file mode 100644 index 0000000..12df538 --- /dev/null +++ b/contracts/shared/ts/models.generated.ts @@ -0,0 +1,95 @@ +// Generated from ../schema.json. Edit the schema and run npm run contracts:generate. + +export type UUID = string; + +export type Revision = number; + +export type Instant = string; + +export type Locale = string; + +export type ErrorCode = string; + +export type MessageArgument = string | number | boolean; + +export type MessageArgs = Record; + +export interface LocalizedMessage { + key: string; + args: MessageArgs; +} + +export type ErrorOrigin = "server" | "client" | "transport"; + +export type ErrorCategory = "validation" | "authentication" | "authorization" | "not-found" | "conflict" | "rate-limit" | "availability" | "internal" | "protocol" | "transport"; + +export type OperationOutcome = "not-applicable" | "not-applied" | "rejected" | "unknown"; + +export type RecoveryAction = "none" | "correct-input" | "authenticate" | "complete-requirements" | "resync" | "resolve-pending" | "retry-same-request" | "change-selection" | "update-client" | "contact-support"; + +export interface Recovery { + action: RecoveryAction; + retryAfterMs?: number; +} + +export interface Violation { + pointer: string; + code: ErrorCode; + message: LocalizedMessage; +} + +export interface ErrorData { + modelVersion: 1; + origin: ErrorOrigin; + code: ErrorCode; + category: ErrorCategory; + outcome: OperationOutcome; + message: LocalizedMessage; + recovery: Recovery; + violations: Array; +} + +export interface Correlation { + requestId?: UUID; + commandId?: UUID; + operationId?: UUID; + subscriptionId?: UUID; +} + +export interface HttpProblem { + type: string; + title: string; + status: number; + correlation: Correlation; + error: ErrorData; +} + +export interface ErrorMessagePayload { + correlation: Correlation; + error: ErrorData; +} + +export interface CommandRejection { + status: "rejected"; + error: ErrorData; +} + +export interface GameCard { + id: string; + version: string; + title: string; + summary: string; + locale: Locale; + minPlayers: number; + maxPlayers: number; +} + +export interface CatalogPage { + items: Array; + nextCursor: string | null; +} + +export interface CatalogResponse { + requestId: UUID; + data: CatalogPage; +} diff --git a/contracts/shared/tsconfig.json b/contracts/shared/tsconfig.json new file mode 100644 index 0000000..3a0f939 --- /dev/null +++ b/contracts/shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", + "strict": true, "noEmit": true, "allowImportingTsExtensions": true, + "skipLibCheck": true, "types": ["node"] + }, + "include": ["*.ts", "ts/*.ts"] +} diff --git a/contracts/shared/validate.ts b/contracts/shared/validate.ts new file mode 100644 index 0000000..a450aeb --- /dev/null +++ b/contracts/shared/validate.ts @@ -0,0 +1,53 @@ +import { readFile } from 'node:fs/promises'; +import { z } from 'zod'; + +const schema = JSON.parse(await readFile(new URL('schema.json', import.meta.url), 'utf8')); +const validators = new Map(); + +// Contract tooling. Browser/native SDKs need their own bounded decoder. +export function validateModel(name: string, value: unknown): unknown { + if (!(name in schema.$defs)) throw new Error(`Unknown shared model: ${name}`); + let validator = validators.get(name); + if (!validator) { + validator = z.fromJSONSchema({ ...schema, $ref: `#/$defs/${name}` }); + validators.set(name, validator); + } + const result = validator.parse(value); + validateRelations(value, name); + return result; +} + +function validateRelations(value: unknown, name: string): void { + if (!value || typeof value !== 'object') return; + const object = value as Record; + if (name === 'ErrorData') { + const recovery = object.recovery as Record; + if (object.outcome === 'unknown' && recovery.action !== 'resolve-pending') + throw new Error('Unknown outcome must resolve the pending operation'); + if (object.outcome === 'rejected' && recovery.action === 'retry-same-request') + throw new Error('A confirmed rejection must not automatically retry'); + if ('retryAfterMs' in recovery && !['retry-same-request', 'resolve-pending'].includes(String(recovery.action))) + throw new Error('Retry delay requires a retry/status recovery'); + } + if (name === 'CommandRejection' && ((object.error as Record).outcome !== 'rejected' || (object.error as Record).origin !== 'server')) + throw new Error('A rejection receipt needs a confirmed server rejection'); + if (name === 'GameCard' && Number(object.minPlayers) > Number(object.maxPlayers)) + throw new Error('Player limits are reversed'); + if (name === 'HttpProblem' || name === 'ErrorMessagePayload') { + const error = object.error as Record; + const correlation = object.correlation as Record; + if (error.outcome === 'unknown' && !correlation.commandId && !correlation.operationId) + throw new Error('Unknown outcome needs an operation/command reference'); + if ('status' in object && error.origin !== 'server') + throw new Error('An HTTP problem must carry a server-origin error'); + if ('type' in object && String(object.type) !== `urn:juegoland:problem:${String(error.code).toLowerCase().replaceAll('_', '-')}`) + throw new Error('Problem type/code mismatch'); + } + // Ignore optional fields unknown to this version; do not interpret their names. + for (const [key, child] of Object.entries(schema.$defs[name].properties ?? {}) as [string, { $ref?: string; type?: string; items?: { $ref?: string } }][]) { + if (child.$ref) validateRelations(object[key], child.$ref.split('/').at(-1)!); + if (child.type === 'array' && child.items?.$ref) { + for (const item of object[key] as unknown[]) validateRelations(item, child.items.$ref.split('/').at(-1)!); + } + } +} diff --git a/contracts/shared/verify.ts b/contracts/shared/verify.ts new file mode 100644 index 0000000..d424fa2 --- /dev/null +++ b/contracts/shared/verify.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { validateModel } from './validate.ts'; +import type { ErrorData, ErrorMessagePayload, HttpProblem } from './ts/models.generated.ts'; + +type Case = { name: string; model: string; value: unknown }; +const fixtures = JSON.parse(await readFile(new URL('fixtures.json', import.meta.url), 'utf8')) as { valid: Case[]; invalid: Case[] }; +for (const fixture of fixtures.valid) validateModel(fixture.model, fixture.value); +for (const fixture of fixtures.invalid) + assert.throws(() => validateModel(fixture.model, fixture.value), `Must reject: ${fixture.name}`); +const find = (name: string) => fixtures.valid.find(v => v.name === name)!.value; +const error = find('room-full') as ErrorData; +assert.deepEqual((find('http-room-full') as HttpProblem).error, error); +assert.deepEqual((find('socket-room-full') as ErrorMessagePayload).error, error); +assert.deepEqual((find('receipt-room-full') as { error: ErrorData }).error, error); +validateModel('ErrorData', { ...error, code: 'FUTURE_GAME_ERROR', futureOptionalField: true }); +validateModel('ErrorData', { ...error, futureOptionalField: { modelVersion: 1, outcome: 'unknown' } }); +assert.throws(() => validateModel('Revision', Number.NaN)); +assert.throws(() => validateModel('Revision', Number.POSITIVE_INFINITY)); +console.log(`${fixtures.valid.length} shared fixtures, ${fixtures.invalid.length + 2} rejections and identical HTTP/socket/receipt errors verified.`); diff --git a/docs/HANDOFF-2026-10-06.md b/docs/HANDOFF-2026-10-06.md new file mode 100644 index 0000000..55d51e0 --- /dev/null +++ b/docs/HANDOFF-2026-10-06.md @@ -0,0 +1,98 @@ +# Handoff de games2 — 6 de octubre de 2026 + +Documento para retomar el **7 de octubre de 2026** en un proyecto de Codex separado del proyecto original `games`. Abrir el **directorio local existente `G:\dev\svelte\games2`**. El usuario ha autorizado cerrar la sesión con commit y push de todo el trabajo del repositorio en `main`; verificar el commit de cierre y el estado de Git al retomar. No crear otra carpeta `games2` ni arrancar desde el commit inicial omitiendo los cambios de esta sesión. + +## Estado real del proyecto + +`games2` es un repositorio y aplicación independientes de `G:\dev\svelte\games`. Tiene un backend Go y un frontend Svelte 5/TypeScript. Hoy la base ejecutable ofrece `GET /live`, `GET /ready`, `GET /api/v2/bootstrap` y `GET /api/v2/catalog`; el catálogo aparece vacío porque todavía no hay juegos propios instalados/publicados. La API local usa `127.0.0.1:8082`; Vite usa `127.0.0.1:4174` y envía `/api` a Go. El README explica cómo arrancarlos. + +Todavía **no funcionan** identidad, salas, chat, motor Go, PostgreSQL, tiempo real, pantallas compartidas ni despliegue de producción de `games2`. `/ready` informa de la fase `foundation`; no certifica que el producto esté listo para usuarios. Los contratos de `docs/` son destino de diseño salvo donde se indique expresamente que hay implementación. No confundir la producción de `games` v1, basada en Node, con este proyecto. + +La portada actual y sus estilos son un **prototipo visual rechazado/no aprobado**. El usuario decidió detener diseño visual y sistema de tokens para encargarlos después a un equipo. No continuar la dirección estética actual como si fuese definitiva. Sí existe un selector español/inglés y negociación básica del catálogo; el resto de la plataforma y de los juegos todavía necesita localización. + +## Decisiones del usuario que deben mantenerse + +- Tres capas lógicas: **API Go, servidor/motor propio Go y frontend Svelte**. API y servidor pueden empezar en un mismo binario modular; los límites entre capas siguen siendo explícitos. +- Solo el equipo publica **juegos propios**. No hay publicación de terceros ni marketplace. El motor es propio; el formato y protocolo de juegos separan reglas, presentación, recursos, componentes (barajas, dados, tableros, etc.), eventos y animaciones. +- El alcance gráfico es **2D, incluida perspectiva de mesa/isométrica, con sprites y animaciones sencillas**. El usuario no contempla 3D, Three.js, iluminación o efectos avanzados, cuestiona el peso de Phaser y pide comparar Konva con Motion y su motor propio en `vicen`. Evaluar primero reutilización del motor propio para cartas/fichas DOM; **Konva queda condicionado a necesitar dibujo y objetos Canvas**, con Phaser como alternativa de mayor alcance, sin selección o integración aprobada. Svelte mantiene interfaz/controles, Go reglas/resultados y un adaptador conserva recuperación/cancelación del coordinador visual propio. Medir tamaño y consumo reales antes de afirmar ligereza. WebGL no es obligatorio y 2D/Canvas/DOM por sí solos no garantizan compatibilidad en TV/Cast. +- Protección del menor transversal: chat desactivado para menores y salas protegidas; chat adulto solo con mayoría acreditada, teléfono verificado por SMS y permisos vigentes. SMS no acredita edad. Nombres, medios, invitaciones, vistas y datos también requieren proyecciones seguras. +- Cada juego declara sus **modos y presentaciones**: `TVBoard` muestra desarrollo público en TV y controles por etapa/privados en móvil; `Desktop`/`Mobil` muestran estado autorizado y controles completos. La mesa presencial se crea con QR de entrada, sin invitaciones. En sala convencional se puede introducir invitación en Smart TV y enlazar su TV personal con un QR de mando; debe poder pasar a móvil completo conservando plaza/recibos y revocando esa TV. Los vínculos común/personal tienen alcance propio; ninguna TV ocupa plaza o envía comandos. El gesto de agitar solicita una acción con botón/teclado alternativos; Go decide azar/resultado. Todo pertenece a **v2 propuesto**, sin esquemas ejecutables/runtime. +- La distribución negocia `player-full`, `player-controller` y `public-board` por suscripción/contrato, con revisión común, reautorización y recibos independientes del dispositivo. El [perfil temporal propuesto](game-engine/synchronization-profile.md) añade investigación de fuentes primarias, reloj/época y rondas con preparación, ingreso duradero, recogida y resolución. Para quiz se recomienda acierto sin premio al primer paquete; carreras compensadas con incertidumbre/empates siguen experimentales. El cambio visual durante reacción se aplica entre rondas; fallo temporal acaba sin derrota por red. No se promete medir reacción humana exacta bajo lag arbitrario. +- Los controles móviles usan la web y podrán usar después una **app independiente Flutter solo de controles**. El tablero en TV y las vistas completas `Desktop`/`Mobil` siguen en la web; Flutter representa únicamente `player-controller` de `TVBoard`, con datos privados necesarios para decidir. El [perfil común de comunicación](platform/client-communication-profile.md) prepara HTTPS/WSS y SDK TypeScript/Dart con los mismos mensajes/acciones del mando. «Jugar solo en el móvil» desde Flutter abre `Mobil` web, autentica al mismo principal y transfiere mando/presentación conservando plaza/recibos antes de revocar la TV personal. La app no ejecuta `.ts` ni se conecta a Socket.IO como si fuera WebSocket simple. Sesión nativa/ticket, negociación, QR y recuperación siguen pendientes; la app se desarrolla posteriormente, sin nuevas plazas o permisos por dispositivo. +- AirConsole es el referente funcional indicado por el usuario para **TV como tablero y móviles como mandos**. La [revisión incorporada al perfil multidispositivo](game-engine/multi-device-profile.md#8-referencia-de-interacción-airconsole) recoge vinculación, controles por fase, información privada y simulador. Conservamos autoridad Go, admisión protegida y TV pasiva; no supone adoptar su SDK/servicio ni continuar el diseño visual aplazado. +- Contemplar **TV no smart con Chromecast o Fire TV conectado**, además de ordenador por HDMI. El [perfil de acceso a pantallas](game-engine/multi-device-profile.md#televisores-y-dispositivos-externos) distingue Silk, receptor Cast propio, duplicación y apps Google TV/Android TV/Fire TV. El dispositivo conectado ejecuta el cliente; la TV no ocupa otra plaza. Soporte real, receptor/registro Cast y apps de TV siguen pendientes; Flutter mantiene su alcance exclusivo de controles. +- Preparar desde el primer juego la posibilidad futura de 100.000 jugadores simultáneos **repartidos entre salas**: estado duradero, recibos/idempotencia, recuperación, límites y métricas. Esa cifra no está demostrada; añadir réplicas, transporte entre nodos o particionado corresponde a carga medida. +- La plataforma será multilingüe. La identidad visual y el sistema de tokens quedan pendientes. +- Cada juego puede admitir jugadores virtuales controlados por agentes IA. El soporte, perfiles e instrucciones deben pertenecer a las propiedades y archivos versionados del propio juego. El [perfil virtual v2 propuesto](game-engine/virtual-player-profile.md) está documentado, sin esquemas ni runtime; las acciones se validan como las de cualquier asiento y el agente solo recibe su información autorizada. + +## Documentos que guían la continuación + +| Tema | Documento | +| --- | --- | +| Estado, arranque y estructura | [README](../README.md) | +| Orden de implementación | [ROADMAP](ROADMAP.md) | +| Entidades, salas, seguridad y operación | [Modelo de plataforma](platform-spec-v2.md) | +| Contratos HTTP y tiempo real | [API y eventos](platform/api-and-events.md) | +| Arquitectura Go de tres capas | [Arquitectura](platform/architecture.md) | +| Políticas de producto y menores | [Políticas](platform/product-policies.md) y [protección del menor](platform/child-safety.md) | +| Idiomas | [Localización](platform/localization.md) | +| Formato/protocolo v1 de juegos | [Formato y protocolo](game-engine/format-and-protocol.md), [propuesta del motor](game-engine-proposal.md) y esquemas/ejemplos de `docs/game-engine/` | +| TV y móvil como superficies del juego | [Perfil multidispositivo v2 propuesto](game-engine/multi-device-profile.md) | +| Reloj, rondas y equidad ante lag | [Perfil de sincronización v2 propuesto](game-engine/synchronization-profile.md) | +| Web y futura app Flutter; acciones y estado bidireccionales | [Perfil de comunicación de clientes v2](platform/client-communication-profile.md) | +| Modelos comunes Go/Svelte/Flutter y gestión de errores | [Modelos compartidos](platform/shared-data-models.md), fuente y bindings bajo `contracts/shared/` | +| Jugadores virtuales e instrucciones dentro del juego | [Perfil de agentes v2 propuesto](game-engine/virtual-player-profile.md) | +| Preparación para 100.000 jugadores | [Capacidad y escalado](platform/capacity-and-scaling.md) | +| Diseño visual aplazado | [Exploración no aprobada](platform/visual-design.md) | +| Evaluación y corrección de los documentos nuevos | [Revisión del 6 de octubre](platform/review-2026-10-06.md) | + +La API documentada aún usa un perfil Socket.IO v1. La arquitectura recomienda estudiar un binding WebSocket Go versionado; cambiar el transporte requiere contrato y pruebas, y no convierte automáticamente los esquemas TypeScript del motor en reglas ejecutables Go. El perfil multidispositivo agrega `game.*` v2 y un binding de plataforma restringido para TV; tampoco está implementado. + +La revisión documental del 6 de octubre propone un mando activo por membresía/partida con `controlGeneration`, barrera de transferencia y operaciones consultables separadas del recibo inmutable. Cierra también la fuente de estímulo por ronda frente a vistas paralelas, el ingreso duradero antes de resolver, unidades de reloj, incidentes acotados sin reutilizar preguntas y autenticación web/nativa. Son diseños pendientes de esquemas/fixtures e implementación; leer el registro antes de programar esos flujos. Arquitectura y modelo ya distinguen la decisión Go del usuario de las propuestas técnicas todavía abiertas. + +Después, el usuario exige modelos interoperables y errores con la misma estructura/información. Se añadió `contracts/shared/schema.json` como fuente neutral y generación TypeScript/Go/Dart para 22 modelos base. `ErrorData` distingue origen, causa, resultado incierto, mensaje traducible y recuperación; HTTP/mensajes/recibos usan ese mismo núcleo. Hay fixtures positivos/negativos y pruebas de serialización Go/Dart; los adaptadores de error del runtime y validadores completos de red siguen pendientes. La ficha pública del catálogo usa el tipo generado en Go y Svelte. No se construyó una app Flutter ni nuevos flujos de plataforma. + +Se mostró una prueba aislada de PixiJS con tablero, cartas y dados fuera del repositorio, verificada en Chrome con WebGL; no acredita compatibilidad en TV ni adopción de la librería. Tras considerar Phaser 4 y Konva, el usuario pide comparar con Motion y su motor propio de `vicen` para animaciones sencillas. La recomendación actual es comprobar primero esa reutilización sobre DOM; Konva se evalúa si aparece una necesidad concreta de objetos/capas Canvas. La selección/integración sigue pendiente de decisión/pruebas; no integrar ahora dependencias gráficas. La revisión de AirConsole incorpora sus vías de TV/app/HDMI y distingue la duplicación en Chromecast antiguo de la app en modelos con Google TV. Para reacción se contempla la demora de la ruta de pantalla completa. + +La aclaración posterior del usuario fija el alcance 2D con perspectiva y retira Three.js del [perfil gráfico](game-engine/multi-device-profile.md#gráficos-y-requisitos-por-juego). Google documenta que el Web Receiver Cast no admite WebGL: mantener renderer sin WebGL para Cast directo, también en juegos 2D. Duplicación desde ordenador y app instalada en Google TV son vías distintas. El soporte real de TV/Chromecast sigue sin probarse; no se garantiza por marca/año o por usar Chromium. + +La investigación confirma que Konva ofrece Canvas 2D, capas/grupos, imágenes, sprites por fotogramas y tweens sin exigir WebGL. La primera prueba propuesta debe medir bundle comprimido, primer dibujo, memoria, fluidez y reposo con tablero/cartas, sprite, perspectiva y cancelación/recuperación; desactivar escucha en el tablero público pasivo y detener animaciones al quedar estático u oculto. No se ha medido menor consumo frente a Phaser/PixiJS ni probado Konva en TV/Cast. Phaser conserva sus capacidades de motor completo como alternativa, con Canvas **deprecado** y funciones nuevas exclusivas de WebGL. El perfil conserva fuentes oficiales y pruebas requeridas; no se instaló Konva, PixiJS, GSAP o Phaser en el proyecto. + +Se inspeccionó `G:/dev/svelte/vicen/src/libs/motion` y el motor relacionado de `src/arts/motion`, de solo lectura. El primero resuelve preferencias; `EngineMotion` contiene presets, WAAPI, spring, FLIP, cancelación/`AbortSignal`, `finished` y traspaso de velocidad, sobre `HTMLElement`. No incluye gestor dedicado de spritesheets, objetos Canvas o timeline general. `arts/scene` sí tiene un driver Canvas 2D, pero orientado a efectos ambientales/decorativos, no un árbol de objetos de tablero. Motion para JavaScript permite HTML/SVG, valores/objetos y secuencias; Konva añade dibujo y gestión de objetos Canvas. La reutilización requiere concretar exportaciones/aliases, puerto DOM, presets y adaptación explícita de SVG si procede. Se detectó que el spring integra `1/60` por callback de rAF sin tiempo transcurrido: corregir antes de usarlo con cadencias variables. No se modificó `vicen`, no se ejecutaron sus tests ni se probó el motor propio en TV/Cast; no copiar o portar por inferencia. El [perfil gráfico](game-engine/multi-device-profile.md#gráficos-y-requisitos-por-juego) recoge alcance y diferencias. + +**Última propuesta del usuario sobre Motion:** añadir una función nueva para no interferir en la actual. Se recomienda `springTimed(config: SpringConfig): MotionRun`, conservando `spring()` y los presets actuales. Usaría timestamps, acumulador y pasos físicos constantes con trabajo acotado; conservaría handles, cancelación, movimiento reducido y traspaso de velocidad. La prueba de games2 evaluaría el nuevo driver, y cualquier migración posterior sería optativa. `springTimed` **no está implementada**, y el commit de esta sesión no modifica el repositorio `vicen`. Al retomar esta línea, verificar cadencias de 30/60/120 Hz, frames irregulares, interrupciones, cancelación e inversión; el coordinador visual sigue recuperando la proyección autoritativa de Go. + +El usuario aclara expresamente que **Phaser 4 es el motor actual y Phaser Editor 5 es el editor**: la discrepancia está resuelta y no queda pendiente un enlace o respuesta sobre versiones. La comprobación del 6 de octubre en el archivo oficial y directamente en el registro npm devuelve `phaser@latest = 4.2.1`. El [perfil gráfico](game-engine/multi-device-profile.md#gráficos-y-requisitos-por-juego) incluye las fuentes. Esta aclaración no aprueba adoptar el motor/editor; la prioridad posterior de reducir peso y comparar la biblioteca propia orienta la siguiente evaluación a reutilizar el motor existente sobre DOM, con Konva condicionado a una necesidad concreta de Canvas. + +## Git y conservación del trabajo + +El repositorio está en la rama `main`, con `origin` configurado para fetch/push a [pippygames](https://g.activething.com/go/pippygames.git), creado por el usuario para este proyecto. El usuario autoriza commit y push de **todo el trabajo de games2** al cerrar el 6 de octubre. El commit de cierre incluye documentación, modelos compartidos TypeScript/Go/Dart, localización del catálogo/API, el prototipo visual y este handoff. La base inicial es `0982ba0` (`Initialize games2 foundation`, 5 de octubre de 2026); no confundirla con la entrega de cierre. Consultar `git log -1 --oneline`, `git status --short --branch` y la correspondencia con `origin/main` al retomar. La demo aislada de PixiJS sigue fuera del repositorio en el directorio de visualizaciones de Codex, y `vicen` no se modificó. + +Al abrir el proyecto nuevo, ejecutar `git status --short` en `G:\dev\svelte\games2` y conservar cualquier cambio posterior al cierre. Una copia o worktree arrancada desde `0982ba0` **no contendrá** el trabajo de esta sesión; usar la entrega de cierre en `main` o un estado posterior verificado. No hacer `reset --hard` ni `clean` para preparar la continuación. El prototipo visual rechazado queda conservado en Git; su presencia no implica aprobación ni permiso para basar en él la interfaz final. + +## Verificación realizada el 6 de octubre + +- `npm run contracts:check` en la raíz: **pasa** (5 manifiestos, 14 mensajes, 37 rechazos, 5 componentes, 52 huecos de arte, plan de presentación y 6 esquemas generados). Comprueba estructura de especificaciones v1, no ejecución de juegos Go ni el perfil multidispositivo v2. +- `npm run check` en `frontend/`: **pasa**, 0 errores y 0 advertencias. +- `npm run build` en `frontend/`: **pasa**. +- `go test ./...` en la raíz: **pasa** al fijar `GOCACHE` dentro del proyecto. El primer intento se detuvo antes de compilar porque la caché global de Windows no tenía permiso de lectura; no fue un fallo del código. En PowerShell: + +```powershell +$env:GOCACHE = 'G:\dev\svelte\games2\.cache\go-build' +go test ./... +``` + +No se ha hecho una prueba de carga, de PostgreSQL, de salas/partidas ni de producción. La compilación del frontend no aprueba el diseño visual. + +En la revisión documental posterior se volvió a ejecutar `contracts:check` con resultado satisfactorio y se comprobaron 13 documentos, 170 referencias locales, 11 ejemplos JSON y coherencia de renderers. No se modificó runtime ni se repitieron las pruebas históricas de frontend/Go en esa revisión. El registro de revisión conserva alcance y límites. + +Antes del commit de cierre se repitieron `npm run contracts:check`, `go test ./...`, `npm run check` y `npm run build` del frontend: **todos pasan**, con 0 errores/advertencias Svelte. También pasan los 14 round trips JSON Dart y `dart analyze contracts/shared/dart` usando directamente el SDK instalado. Se comprobaron 4 documentos de continuación, 63 enlaces locales y 2 bloques JSON sin incidencias. La comprobación Svelte y el build se ejecutaron fuera del sandbox porque Vite necesitaba escribir su caché temporal; no se cambió la configuración del proyecto para esta verificación. Estos resultados no acreditan los flujos pendientes, la estética del prototipo ni compatibilidad TV/Cast. + +## Siguiente trabajo recomendado + +1. Abrir el directorio existente como proyecto nuevo y revisar `git status`, el commit de cierre, este handoff, `README` y `ROADMAP`. Conservar cualquier cambio nuevo antes de crear ramas o worktrees; la propuesta `springTimed` sigue pendiente si se retoma primero la prueba visual. +2. Empezar el primer corte vertical: PostgreSQL, migraciones, unidad de trabajo, repositorios, recibos y outbox duraderos; contratos ejecutables y pruebas de integración. Desde ese corte aplicar las invariantes de [preparación para escalar](platform/capacity-and-scaling.md#2-prepararla-desde-el-primer-juego-sin-pagar-el-clúster-ahora). +3. Implementar un primer juego propio pequeño (Conecta 4) con motor Go, estado versionado, proyección por jugador, azar si procede y recuperación tras reinicio. Definir antes el perfil ejecutable Go del paquete, incluyendo soporte opcional de jugadores virtuales e instrucciones del agente en sus propiedades; los esquemas v1 TypeScript son referencia semántica, no código listo para Go. Probar el primer controlador junto con sus decisiones duraderas, privacidad y límites antes de habilitarlo. +4. Continuar protección, tiempo real, multidispositivo e idiomas por cortes del roadmap. Mantener desactivada cualquier capacidad no implementada en `bootstrap`. No prometer 100.000 jugadores hasta probar la topología real. + +Si el objetivo de la próxima sesión cambia, estas decisiones y el estado de Git siguen siendo el punto de partida; el proyecto nuevo no necesita copiar archivos desde `games` v1. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7628c75..1bfb7e4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -5,19 +5,26 @@ - 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. +- Prototipo visual no aprobado y selector español/inglés; negociación de idioma en el catálogo. La identidad visual y el sistema de tokens se definirán después. La cobertura de idiomas de juegos y flujos futuros se rige por [localización](platform/localization.md). - Contratos y políticas de v2 copiados bajo `docs/`; su implementación permanece pendiente. +- [Modelos compartidos](platform/shared-data-models.md) con fuente JSON Schema, DTOs TypeScript/Go/Dart y fixtures: primitivas, mensajes, errores y catálogo. La ficha de catálogo Go/Svelte usa el tipo generado; adaptadores de error y validadores de red aún pendientes. ## 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. +Aplicar los hallazgos de la [revisión del 6 de octubre](platform/review-2026-10-06.md) al concretar los contratos: mando con generación y transferencia segura, operaciones consultables, fuente temporal única, cierre con ingresos duraderos y límites de incidentes/IA. Las correcciones son documentales, no capacidades habilitadas. + +1. **Persistencia y contratos:** PostgreSQL, migraciones nuevas, repositorios dentro de una unidad de trabajo, API/esquemas verificables y pruebas de integración. Desde este corte aplicar las [invariantes de preparación para escalar](platform/capacity-and-scaling.md#2-prepararla-desde-el-primer-juego-sin-pagar-el-clúster-ahora): estado duradero, límites y métricas, sin autoridad en memoria de un proceso. `/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. Preparar el [perfil de jugadores virtuales](game-engine/virtual-player-profile.md): soporte e instrucciones en las propiedades del paquete, controladores humano/IA por asiento, esquemas ejecutables y decisiones duraderas. Conecta 4 como primer juego propio real y primer ensayo de agente; habilitarlo solo tras comprobar privacidad, límites, fallos y recuperación. 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. +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. Aplicar el [perfil común web/Flutter](platform/client-communication-profile.md): esquemas/fixtures neutrales, SDK TypeScript/Dart, runtime/renderer negociado, autenticación inicial por ticket y sesión nativa con permisos comunes. Primero cliente web; app independiente Flutter **solo de controles** después, con mandos registrados/declarativos, QR/enlaces y recuperación verificados. Tableros y vistas completas siguen en la web; probar el paso Flutter → `Mobil` web con autenticación, transferencia de mando y recibos conservados. El cliente recupera la partida tras reinicio y no depende del nodo anterior ni de ejecutar bundles web en Flutter. +6. **Perfil multidispositivo por juego:** esquemas y fixtures del [contrato v2](game-engine/multi-device-profile.md) para `TVBoard`, `Desktop` y `Mobil`, tres proyecciones negociadas por suscripción, vínculos comunes/personales, mesa por QR sin invitaciones y enlace de mando tras invitación en Smart TV. Este último exige cambio a móvil completo conservando plaza/recibos. AirConsole sigue como referencia de controles por fase y simulador. Probar privacidad, protección, revocación/reconexión, TV/HDMI y gesto más botón/teclado; el gesto nunca decide el azar. Materializar el [perfil temporal](game-engine/synchronization-profile.md): reloj/época, preparación, ingresos duraderos y recogida de rondas; primer quiz por acierto sin ventaja de llegada. Compensación de reacción/empates solo tras ensayos con jitter, asimetría, fraude temporal y cambio de presentación. +7. **Operación y capacidad:** 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. Ensayar el [objetivo propuesto de 100.000 jugadores simultáneos](platform/capacity-and-scaling.md) por escalones y fallos; publicar solo la capacidad demostrada con hardware, coste y márgenes documentados. + +Dentro del corte multidispositivo, contemplar también **TV no smart con dispositivo HDMI** según la [matriz de acceso](game-engine/multi-device-profile.md#televisores-y-dispositivos-externos): verificar primero web/HDMI y Fire TV con Silk; planificar receptor Cast propio y apps de TV para Google TV/Android TV/Fire TV en entregas específicas. Distinguir Chromecast clásico, Chromecast con Google TV y duplicación de pestaña. Probar vínculos públicos, QR, mando/foco, suspensión/cambio de sesión y demora real de pantalla. El alcance decidido es **2D, incluida perspectiva, con sprites y animaciones sencillas**, sin Three.js, motor 3D, iluminación o efectos avanzados. Tras cuestionar el peso de Phaser, el usuario pide comparar Konva con Motion y su motor propio de `vicen`: evaluar primero una presentación DOM con ese motor, concretando exportaciones/puerto DOM/presets y evaluando la nueva función propuesta `springTimed`, con timestamps/acumulador y pasos físicos constantes, sin modificar `spring` ni sus consumidores actuales. La función no está implementada; probar cadencias variables, cancelación, inversión y movimiento reducido antes de integrarla. Probar tablero/cartas y controlador de fotogramas si hace falta; comparar con Konva solo si se justifica Canvas. Medir bundle comprimido, primer dibujo, memoria, fluidez y reposo antes de decidir; Phaser queda como alternativa y su editor se evalúa por separado. Adaptar la solución al coordinador visual propio y conservar Svelte para interfaz/controles y Go para reglas/resultados. La integración sigue pendiente, sin aprobación implícita por la demo PixiJS. WebGL no se exige globalmente: probar perspectiva, secuencias, límites, cancelación y movimiento reducido sin WebGL. El renderer Cast directo debe funcionar sin WebGL y comprobarse en dispositivos reales; Canvas o DOM por sí solos no prueban compatibilidad de navegador. ## 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. +Cada entrega incluye una ruta visible, su comportamiento de error y permisos, cobertura en `es` y `en`, 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. diff --git a/docs/game-engine-proposal.md b/docs/game-engine-proposal.md index cb9ae62..bf5ba63 100644 --- a/docs/game-engine-proposal.md +++ b/docs/game-engine-proposal.md @@ -4,7 +4,9 @@ Estado: diseño consolidado el 5 de octubre de 2026, con contratos y validadores 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. +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 y servidor/motor propio Go para `games2`; 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. + +Ampliación decidida el 6 de octubre: un juego podrá admitir plazas controladas por agentes IA y deberá declararlo en sus propiedades, incluyendo sus instrucciones. El [perfil de jugadores virtuales v2](game-engine/virtual-player-profile.md) concreta la propuesta de paquete, privacidad y ejecución; todavía no cambia los contratos ejecutables v1. El controlador decide una acción fuera de la transacción y las reglas conservan su autoridad. ## 1. Qué constituye la especificación @@ -83,6 +85,8 @@ Un juego contiene obligatoriamente: - 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. +En el futuro formato v2, el soporte opcional de [jugadores virtuales](game-engine/virtual-player-profile.md) pertenece también al paquete: perfiles, modalidades, dificultades, instrucciones incluidas y límites. No se deduce de la categoría del juego ni se activa desde un prompt genérico de plataforma. + Las bibliotecas tienen `kind: components` o `kind: assets` y exportaciones con nombre. Una referencia a dependencia es un objeto inequívoco: ```json @@ -149,6 +153,10 @@ Cada carta pertenece a exactamente una zona. Una zona tiene propietario opcional 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. +El [perfil multidispositivo v2](game-engine/multi-device-profile.md) distingue `TVBoard` (TV pública + mando por etapa), `Desktop` y `Mobil` (vista completa autorizada + controles). Cada juego declara presentaciones/proyecciones versionadas y combinaciones de modos: mesa presencial con QR sin invitaciones, o admisión convencional que puede enlazar una TV personal. El último recorrido exige cambiar a móvil completo sin nueva plaza ni consumo de invitación. La distribución negocia contrato por suscripción; no reutiliza vista privada ni observador v1 en TV. El gesto de agitar solo solicita una acción cuya aleatoriedad sigue en el servidor. + +El [perfil de sincronización temporal v2](game-engine/synchronization-profile.md) propone reloj de servidor, rondas y recogida duradera de inputs; revisiones y reconexión no bastan para ordenar reacción humana. La primera llegada no decide automáticamente quién respondió antes. Ventanas de acierto y carreras con compensación/empates son modelos distintos; las últimas siguen experimentales. Cambiar de presentación conserva recibos y se coordina con la fuente temporal fijada para la ronda. Todo ello requiere contratos ejecutables nuevos y no modifica el SDK v1 implícitamente. + 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 diff --git a/docs/game-engine/format-and-protocol.md b/docs/game-engine/format-and-protocol.md index b1b2e83..74eca4a 100644 --- a/docs/game-engine/format-and-protocol.md +++ b/docs/game-engine/format-and-protocol.md @@ -4,6 +4,12 @@ El catálogo se limita a juegos propios publicados por el equipo. La [propuesta **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. +**Ampliación multidispositivo:** el [perfil de superficies y controles v2](multi-device-profile.md) declara `TVBoard`, `Desktop` y `Mobil`, proyecciones diferenciadas y distribución por suscripción/contrato. Distingue mesa por QR sin invitaciones y TV personal enlazada tras invitación, con cambio a móvil completo. El [perfil temporal](synchronization-profile.md) propone reloj, rondas y equidad de respuestas. No se modifica silenciosamente este formato/protocolo v1 ni se consideran implementadas estas ampliaciones por existir documentación. + +**Clientes web y nativos:** el [perfil común de comunicación v2](../platform/client-communication-profile.md) contempla web Svelte y una app Flutter posterior **solo de controles**: datos/acciones neutrales del mando, SDK TypeScript/Dart, controles por runtime, acceso propio y WSS bidireccional. Tableros y vistas completas permanecen en la web; Flutter solo representa `player-controller` de `TVBoard`. Las firmas/módulos TypeScript y eventos Socket.IO de v1 no se ejecutan en Dart ni se convierten a WebSocket simple automáticamente. + +**Jugadores virtuales:** el [perfil de agentes v2](virtual-player-profile.md) propone que cada juego declare esta opción en sus propiedades e incluya las instrucciones versionadas de sus agentes. La plataforma ejecuta los perfiles admitidos y el motor valida sus acciones. Es una ampliación pendiente, sin campos nuevos en los esquemas v1. + 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 @@ -21,6 +27,8 @@ El manifiesto de distribución es `game.json`. Una biblioteca de componentes o r | `dependencies` | Lista de `{ id, version, kind }`, con tipo `components` o `assets`. | | `kind` | `game`, `components` o `assets`. | +Los catálogos declarados en `metadata.locales` forman parte de la distribución versionada. La publicación interna debe exigir cobertura de los idiomas activos de la [plataforma](../platform/localization.md), incluidos reglas, acciones, ayuda, textos alternativos y recursos que contengan texto. El idioma elegido solo modifica presentación; identificadores, acciones y resultados del motor son estables entre lenguas. + El manifiesto de juego añade: | Campo | Estructura | diff --git a/docs/game-engine/multi-device-profile.md b/docs/game-engine/multi-device-profile.md new file mode 100644 index 0000000..7d66973 --- /dev/null +++ b/docs/game-engine/multi-device-profile.md @@ -0,0 +1,427 @@ +# Perfil multidispositivo del juego — propuesta de contrato v2 + +**Estado:** especificación de diseño para implementar y revisar con el equipo. No está soportada por el runtime, los esquemas ejecutables ni la API actual de `games2`. El [formato y protocolo v1](format-and-protocol.md) permanece sin cambios para los juegos existentes. Esta propuesta requiere versiones nuevas de manifiesto, SDK de reglas, presentación y mensajes; no se añaden campos a v1 fingiendo compatibilidad. + +## 1. Decisión de modelo + +El juego declara sus **superficies** y **métodos de entrada**. Un televisor, proyector o navegador conectado por HDMI puede ser una `shared-display`: muestra una proyección común de la partida, pero no ocupa plaza, no es un jugador, no recibe una mano privada y no envía acciones de juego. Un móvil, tableta u ordenador usado por una persona es una superficie `player`: su sesión autenticada se vincula a un asiento y puede mostrar datos privados y acciones autorizadas. Un jugador puede usar el móvil como mando sin que eso convierta al sensor en fuente de verdad. + +El usuario concreta tres **perfiles de presentación** que cada juego declara en sus propiedades: + +| Perfil | Vista y controles | Creación y entrada a la mesa | +| --- | --- | --- | +| `TVBoard` | La TV muestra el desarrollo público completo: tablero, fase, turno, resultados y eventos. El móvil ofrece los controles específicos de cada etapa y los datos privados necesarios. | Al crear una mesa presencial, entrada por QR visible en TV, sin invitaciones. Al acceder con una invitación a una sala convencional compatible, QR personal para enlazar móvil y TV del jugador. | +| `Desktop` | Vista completa autorizada del jugador, con estado y controles integrados para ordenador. | Flujo convencional de creación/admisión de sala; invitaciones cuando correspondan a su política. | +| `Mobil` | Vista completa autorizada del jugador, con estado y controles adaptados al móvil. | El mismo flujo convencional, presentado para móvil. | + +«Completa» se refiere a la vista que corresponde a su destinatario: la TV no revela manos ni roles ocultos. `Mobil` y el mando móvil de `TVBoard` tienen responsabilidades distintas; el primero permite jugar con su propia vista completa, mientras el segundo acompaña al tablero común. + +Los controles móviles pueden usar **la web de la plataforma o una futura app independiente Flutter solo de controles**. Perfil visual y runtime son ejes distintos: la web representa `TVBoard`, `Desktop` y `Mobil`; Flutter únicamente el mando `player-controller` de `TVBoard`, con sus datos privados necesarios. El tablero común/personal y la vista completa siguen en la web. El [perfil de comunicación web/nativa](../platform/client-communication-profile.md) define transporte bidireccional, autenticación por cliente, compatibilidad y recuperación. No exige instalar la app para jugar desde web ni traslada las reglas al móvil. + +El **modo de sala** (`modeId`) fija interacción, requisitos de pantalla y método de entrada; el **perfil visual** (`presentationProfile`) elige cómo se representa una superficie compatible. Crear una mesa presencial desde `TVBoard` selecciona el modo de mesa con QR. Crear desde `Desktop` o `Mobil` selecciona el flujo personal convencional. Un jugador que ya recibió una invitación también puede usar `TVBoard` si el modo existente lo admite, sin crear otra mesa ni cambiar su entrada. Cambiar entre perfiles visuales admitidos conserva asiento, reglas y estado; no convierte una mesa con QR en una sala de invitaciones ni cambia su admisión durante la partida. + +La pantalla común de una mesa usa `displayScope: room`. La TV personal de un jugador invitado usa `displayScope: participant`, vinculada a su membresía y a esa partida: muestra la misma proyección pública de tablero, pero su permiso de entrega depende del acceso vigente de ese jugador. No ocupa otra plaza ni es un espectador remoto independiente. Su mando móvil recibe la información privada del asiento. Varias personas pueden usar su propia TV en una sala compatible; no comparten la sesión personal de pantalla. + +Se mantiene fuera del alcance el espectador remoto con cuenta propia. Una pantalla compartida es una sesión de dispositivo vinculada expresamente a **una sala** y con alcance de lectura mínimo. Cualquier persona físicamente delante de ella podría ver sus píxeles; por eso su contenido se diseña como visible para todos los presentes, especialmente en salas protegidas. + +Ejemplos de adaptación, no juegos anunciados ni licencias de marcas ajenas: + +| Tipo de juego | Pantalla compartida | Superficie personal | +| --- | --- | --- | +| Preguntas por equipos | Pregunta, tiempo y marcador una vez autorizados para todos. | Selección de respuesta; confirmación privada hasta la revelación. | +| Tablero económico | Tablero, turnos, movimientos y resultados públicos. | Cartas, decisiones, saldo o recursos que las reglas mantengan ocultos. | +| Rol | Mapa revelado, escena y tiradas públicas. | Ficha y secretos de cada personaje; la dirección de juego conserva una proyección privada propia. | +| Dados con cubilete | Animación del resultado **confirmado** por servidor. | Botón de lanzar o gesto de agitar para solicitar exactamente la misma acción. | + +## 2. Extensión versionada del paquete + +El perfil se introduce en `game.json` con `formatVersion: 2`, `engineApiVersion: 2` y `presentation.apiVersion: 2`; `protocolVersion: 2` identifica los sobres de red. Estas versiones no son alias entre sí. Los paquetes v1 no adquieren pantalla compartida por inferencia. Un paquete v2 sin `sharedDisplay` conserva solo la superficie `player`. + +Cada juego publica uno o más **modos de interacción** con identificadores estables y los perfiles de presentación permitidos en ellos. La sala elige `modeId` al crearse y lo fija para toda la partida, junto a la versión/digest del paquete y su `entryMethod`. El ejemplo usa `personal` para el flujo convencional con vistas completas o TV personal opcional, y `mesa` para el tablero común con entrada QR; esos identificadores de modo son propios del juego. No se cambia de modo al perder conexión ni por preferencia local de un navegador: un cambio de reglas, visibilidad o método de entrada requiere una transición explícita, versionada y autorizada. + +Forma propuesta del fragmento nuevo (los nombres se fijarán al generar los esquemas v2): + +```json +{ + "interaction": { + "profileVersion": 1, + "modes": [ + { + "id": "personal", + "entryMethod": "standard", + "presentationProfiles": ["Desktop", "Mobil", "TVBoard"], + "defaultPresentationProfile": "Desktop", + "sharedDisplay": "optional", + "displayScope": "participant", + "onLoss": "continue", + "fallbackPresentationProfile": "Mobil" + }, + { + "id": "mesa", + "entryMethod": "screen-qr", + "presentationProfiles": ["TVBoard"], + "defaultPresentationProfile": "TVBoard", + "sharedDisplay": "required-at-start", + "displayScope": "room", + "onLoss": "pause-with-timeout", + "lossTimeoutSeconds": 120, + "onTimeout": "cancel-without-result" + } + ], + "sharedDisplay": { + "projection": "public-board" + }, + "inputBindings": [ + { + "id": "shake-cup", + "modes": ["personal", "mesa"], + "surface": "player", + "input": "motion.shake", + "actionType": "roll-dice", + "fallbacks": ["button", "keyboard"] + } + ] + }, + "rules": { + "projections": { + "player-full": { + "viewSchemaVersion": 1, + "eventSchemaVersion": 1, + "viewSchema": "schemas/player-full-view.json", + "contextSchema": "schemas/player-full-context.json", + "visibleEventSchema": "schemas/player-full-event.json" + }, + "player-controller": { + "viewSchemaVersion": 1, + "eventSchemaVersion": 1, + "viewSchema": "schemas/controller-view.json", + "contextSchema": "schemas/controller-context.json", + "visibleEventSchema": "schemas/controller-event.json" + }, + "public-board": { + "viewSchemaVersion": 1, + "eventSchemaVersion": 1, + "viewSchema": "schemas/shared-view.json", + "contextSchema": "schemas/shared-context.json", + "visibleEventSchema": "schemas/shared-event.json" + } + } + }, + "presentation": { + "apiVersion": 2, + "profiles": [ + { + "id": "TVBoard", + "labelKey": "presentation.tvBoard", + "surfaces": { + "shared-display": { + "projection": "public-board", + "renderers": { + "web": { + "rendererId": "preguntas-equipo.web.tv-board", + "rendererVersion": "1.0.0", + "rendererApiVersion": 2, + "entry": "presentation/tv-board.ts" + } + } + }, + "player": { + "projection": "player-controller", + "renderers": { + "web": { + "rendererId": "preguntas-equipo.web.controller", + "rendererVersion": "1.0.0", + "rendererApiVersion": 2, + "entry": "presentation/mobile-controller.ts" + }, + "flutter": { + "rendererId": "preguntas-equipo.flutter.controller", + "rendererVersion": "1.0.0", + "rendererApiVersion": 1 + } + } + } + } + }, + { + "id": "Desktop", + "labelKey": "presentation.desktop", + "surfaces": { + "player": { + "projection": "player-full", + "renderers": { + "web": { + "rendererId": "preguntas-equipo.web.desktop", + "rendererVersion": "1.0.0", + "rendererApiVersion": 2, + "entry": "presentation/desktop.ts" + } + } + } + } + }, + { + "id": "Mobil", + "labelKey": "presentation.mobil", + "surfaces": { + "player": { + "projection": "player-full", + "renderers": { + "web": { + "rendererId": "preguntas-equipo.web.mobile-full", + "rendererVersion": "1.0.0", + "rendererApiVersion": 2, + "entry": "presentation/mobile-full.ts" + } + } + } + } + } + ] + } +} +``` + +Cada modo declara `sharedDisplay: off|optional|required-at-start`. Solo `optional` y `required-at-start` pueden vincular una pantalla. El último debe anunciarse en el catálogo y no inicia sin una pantalla vinculada y activa. Si el modo admite pantalla, declara `onLoss: continue|pause-with-timeout`; el segundo pausa la admisión de nuevas acciones, sin adjudicar derrota por un corte de red. Para `pause-with-timeout` son obligatorios `lossTimeoutSeconds` acotado y `onTimeout: continue-without-display|cancel-without-result`, fijados al crear la sala. Los plazos de juego se ajustan o reprograman de forma transaccional si una pausa está permitida. Al vencer, el servidor ejecuta la salida declarada; si se permite continuar sin pantalla, ese fallback forma parte del mismo modo y no cambia el `modeId`. La transición exacta requiere pruebas de abuso antes de publicar el modo. En `off` no se incluyen reglas de pérdida de pantalla; en `optional` sin pantalla vinculada al empezar tampoco se inicia una pausa. + +`sharedDisplay` existe solo si al menos un modo la permite; en ese caso sus esquemas y funciones son obligatorios. Los contratos de vista/contexto/eventos pertenecen a `rules.projections` y los renderers a `presentation.profiles[].surfaces..renderers`; las referencias deben coincidir, sin duplicar definiciones que puedan discrepar. `inputBindings.modes` es una lista no vacía de modos publicados donde el gesto es válido. El juego puede omitir por completo `motion.shake`; una categoría como `board` o `roleplaying` no activa automáticamente sensores ni TV. En el catálogo se ofrecen perfiles, modos, runtimes compatibles y requisitos por juego, y la sala conserva su selección para autorización, reconexión y replay. + +Cada perfil tiene identificador único, nombre traducible y renderers reales/versionados para sus superficies y runtimes ofrecidos. `TVBoard` exige `shared-display` y `player`; `Desktop` y `Mobil` exigen `player` y representan estado más controles. Los perfiles de un modo y su predeterminado deben existir y ser compatibles con sus superficies. Un juego publica solo combinaciones que implemente y verifique. Un renderer web declara su `entry`; uno Flutter referencia controles registrados en la app, o un modelo declarativo versionado que esa app admite. Flutter solo se admite en `TVBoard` / `player` / `player-controller`, nunca en `player-full` o `public-board`; publicador y negociación comprueban esa restricción. Las versiones/API del renderer son independientes del protocolo de red; una entrada `.ts` no prueba soporte Flutter. Pueden compartir datos/recursos, pero los comportamientos se prueban por cliente. El fragmento es ilustrativo, incluidos sus controles nativos, y no constituye un paquete instalable; los plazos se validan por juego antes de publicar. + +El modo de creación de mesa presencial `TVBoard` usa `entryMethod: screen-qr`, `displayScope: room` y requiere pantalla al inicio. El flujo convencional `standard` puede admitir `Desktop`, `Mobil` y `TVBoard` con pantalla personal opcional, sin modificar cómo se entra en la sala. El juego declara las combinaciones; no se infieren al detectar el dispositivo. En el ejemplo, la mesa común pausa con plazo y cancela sin resultado al perder TV; el modo personal continúa y permite fallback a la vista completa `Mobil`, previa resincronización y conservación de recibos pendientes. El fallback solo afecta al jugador que perdió su TV. + +La ausencia temporal de TV no autoriza a cambiar de modo. Un perfil visual solo cambia por elección compatible o por un fallback publicado explícitamente. Continuar sin pantalla exige una presentación completa alternativa y una transición declaradas y probadas. `fallbackPresentationProfile` debe existir, ofrecer `player` con estado y controles completos y pertenecer al mismo modo; nunca concede permiso para nuevas superficies. + +La selección de `Desktop` o `Mobil` puede sugerirse según las capacidades del dispositivo, respetando la elección explícita y las opciones del juego. `TVBoard` se elige expresamente al preparar la mesa. Cambiar un perfil personal compatible resincroniza su presentación y mantiene los comandos pendientes con sus identificadores. Si también cambia el cliente activo, la [barrera de mando](../platform/client-communication-profile.md#continuidad-del-mando-entre-conexiones) resuelve los ingresos anteriores y excluye nuevos comandos de la generación sustituida antes de admitir acciones del destino. El redimensionado no cambia reglas, identidad ni método de admisión. + +En el acceso convencional con invitación, TV personal y móvil mando, **«Jugar solo en el móvil» es una opción obligatoria**. Un paquete que ofrezca ese recorrido debe incluir `Mobil` web en el mismo modo, con `player-full`, recursos y traducciones correspondientes. Desde Flutter se abre la web, se autentica al mismo principal y se prepara la suscripción destino antes de confirmar transferencia de mando/presentación; no se comparte implícitamente la sesión ni se exporta su credencial permanente. No depende de que falle la TV: el jugador puede solicitarlo por elección propia. El servidor conserva sala, membresía, asiento y recibos, sustituye la suscripción del mando por la vista completa y revoca el vínculo personal de TV al completar el cambio. Un paso fallido no revoca anticipadamente la TV ni crea otra plaza. No consume otra invitación ni altera las pantallas de otros jugadores. Volver a TV exige un vínculo autorizado nuevo. Esta exigencia pertenece al recorrido de TV personal; las mesas presenciales pueden declarar otros requisitos. + +El [perfil de sincronización](synchronization-profile.md) fija cuándo se aplica el cambio: en juegos por turnos puede completarse con la barrera habitual; en una ronda de reacción se solicita en cualquier momento y se aplica entre rondas, conservando la fuente de presentación y mediciones fijadas para la ronda actual. Si esa fuente se pierde y ya no permite jugar, la política temporal resuelve la ronda sin penalización por red antes de completar el cambio. Cambiar de vista nunca reinicia el cronómetro ni concede otra respuesta. + +`inputBindings` se refiere a entradas registradas por la plataforma, no a nombres de sensores arbitrarios ni a código ejecutable del manifiesto. Cada binding identifica un tipo de acción existente y alternativas de botón y teclado obligatorias. El publicador comprueba que la presentación solo puede activar una oferta vigente del servidor y que no hay dos bindings ambiguos para la misma acción y modo. La ausencia de permiso o hardware elimina el gesto, **no** la acción. Las categorías de juego (`board`, `quiz`, `roleplaying`, etc.) sirven para catálogo; no sustituyen estas declaraciones concretas. + +Cada superficie tiene renderers y recursos propios; los assets públicos pueden descargarse, así que su nombre o ruta no contiene secretos de una partida. El renderer nativo registrado y el web implementan contratos compatibles sin compartir código ejecutable por inferencia. Traducciones, texto alternativo, contraste, sonido y movimiento reducido se validan por superficie/cliente. La presentación compartida no importa el módulo de reglas privadas ni recibe datos para filtrarlos en el navegador. + +## 3. Contrato del motor y privacidad + +El SDK v2 distingue tres proyecciones con esquemas propios: + +| Proyección | Funciones propuestas | Destinatarios y contenido | +| --- | --- | --- | +| `player-full` | `project(position, recipient)` y `projectEvent(...)` | `Desktop` y `Mobil`: estado completo autorizado del jugador, contexto, resultado y eventos personales. | +| `player-controller` | `projectController(position, recipient)` y `projectControllerEvent(...)` | Móvil `TVBoard`: etapa, contexto necesario para actuar, datos privados de su plaza y eventos pertinentes al mando. No se envía todo el tablero común para que el móvil lo descarte. | +| `public-board` | `projectSharedDisplay(position)` y `projectSharedEvent(...)` | TV común o personal: desarrollo público, fase, marcador, resultados y efectos públicos. Sin secretos, chat, perfiles ni comandos. | + +Todas las funciones de proyección son puras y tienen contratos versionados en `rules.projections`. `availableActions(position, recipient)` sigue siendo la autoridad de ofertas personales para `player-full` y `player-controller`; las acciones habilitadas y sus precondiciones conservan la misma semántica en ambas. El mando no habilita un movimiento distinto por disponer de menos datos de presentación. Para una pantalla `public-board` las ofertas son siempre vacías y no se autoriza ejecutar comandos. Un juego no declara `TVBoard` sin ambas proyecciones, esquemas, entradas y pruebas. + +La salida compartida no se obtiene de `project(position, { kind: "observer" })` ni de copiar la vista de un anfitrión. El observador de v1 representaba una eventual persona espectadora y no establecía la política de una pantalla accesible a presentes. Tampoco se calcula simplemente como intersección JSON de vistas personales: claves opacas, tiempos y metadatos podrían seguir filtrando información. Se exige una proyección positiva que incluya solo campos explícitamente públicos. + +Una pantalla no modifica `Position`, `Flow`, `actorRevisions`, asientos ni aleatoriedad. El resultado de dados, cartas y ruletas lo determina el servidor; el gesto físico solo solicita una acción. Con la misma posición, acción y traza de azar, los efectos de reglas son iguales con independencia de si se invocó mediante toque, teclado o movimiento. + +## 4. Mensajes de partida v2 + +`game.sync` solicita `surface: "player" | "shared-display"` y `presentationProfile: "TVBoard" | "Desktop" | "Mobil"`, y declara la tupla exacta de reglas, proyección, versiones de vista/eventos y presentación de esa combinación. El servidor deriva `projection` de la combinación publicada y valida el decodificador declarado por el cliente; no acepta una proyección arbitraria como permiso. El snapshot identifica el `modeId` fijado en la sala y el perfil/proyección aceptados; un cliente no propone el modo como autoridad. El servidor deriva el asiento del contexto autenticado `player`, o la sala, alcance y permiso de lectura del vínculo de pantalla. En `displayScope: participant` revalida también la membresía propietaria; ese vínculo no entrega su vista privada a la TV. Perfil y superficie nunca conceden acceso por sí mismos. Se rechazan perfiles ausentes/incompatibles y, por ejemplo, `Desktop` con `shared-display`, antes de crear suscripción. El cliente no envía `actor`, datos de estado ni `displayId` como autoridad. El contexto de pantalla negocia además un binding de plataforma v2 restringido, separado del `platform.*` v1 de cuentas. + +```json +{ + "protocolVersion": 2, + "type": "game.sync", + "matchId": "cc749a64-c8e4-4d74-a913-54b39f4c9089", + "requestId": "226b3650-0abd-43ae-b49a-11db88bfb6e2", + "surface": "shared-display", + "presentationProfile": "TVBoard", + "knownRevision": null, + "client": { + "engineApiVersion": 2, + "presentationApiVersion": 2, + "clientRuntime": "web", + "presentations": [{ + "id": "preguntas-equipo", + "rulesVersion": "1.0.0", + "surface": "shared-display", + "presentationProfile": "TVBoard", + "projection": "public-board", + "rendererId": "preguntas-equipo.web.tv-board", + "rendererVersion": "1.0.0", + "rendererApiVersion": 2, + "viewSchemaVersion": 1, + "eventSchemaVersion": 1 + }] + } +} +``` + +`game.snapshot` devuelve `surface`, `presentationProfile`, `projection`, `subscriptionId`, revisión, estado, `view` y eventos de **esa** proyección, junto a su tupla de contrato. Para `shared-display` identifica también el `displayScope` derivado del vínculo; `context` solo contiene datos públicos y `availableActions` es siempre `[]`. El servidor rechaza cualquier `game.command` o `game.command-status` de esa sesión. `Desktop`/`Mobil` reciben la vista `player-full` y el mando `TVBoard` recibe `player-controller` de su propio asiento; cambiar la forma visual no expone el estado global. Se mantienen las barreras de sincronización, cursores, detección de huecos e idempotencia del protocolo v1. Al reconectar una pantalla se entrega una instantánea actual y se omiten animaciones históricas; no se reproducen sonidos o efectos antiguos. Una revocación cierra la suscripción y borra la proyección local. + +`game.command` personal conserva `commandId`, precondición y `action`, y añade `controlGeneration` en v2. No incluye un campo confiable de «se agitó» ni aceleraciones. El servidor valida asiento, mando activo/generación, turno, oferta, esquema y cuota; responde con el mismo recibo tanto si el origen fue botón como gesto. Busca primero el recibo propio ya confirmado: recuperar una jugada no exige conservar el mando. El estado pendiente aparece en el móvil hasta el acuse; la pantalla anima solo después de la revisión confirmada. Ninguna animación ni temporizador local decide el resultado. + +Los eventos visibles de cada superficie llevan la revisión confirmada y una referencia de evento para deduplicar; la presentación puede escalonar introducción, transición y reposo, pero nunca retrasa la autorización ni convierte un efecto en estado de partida. El momento de revelar una respuesta, carta o escena pública lo decide la transición de reglas y su proyección, no la duración de una animación local. Un cliente que entra tarde o reconecta toma la vista actual y puede omitir efectos anteriores sin perder información necesaria para jugar. + +### Distribución de mensajes y vínculo entre dispositivos + +El servidor mantiene un registro duradero de vínculos: pantalla, sala, finalidad/alcance, membresía propietaria cuando sea personal, generación, caducidad y revocación. El móvil conserva su sesión humana y membresía; un vínculo de mando no transmite sus credenciales a la TV ni permite que esta lo impersonifique. Una TV de mesa se vincula a la sala y acompaña a varios mandos; una TV personal se vincula al acceso de una sola membresía. Los campos de destino se derivan de esos registros, no de un `targetDeviceId`, `actor` o `displayScope` enviado en una jugada. + +| Conexión | Contrato de salida | Puede enviar acciones | +| --- | --- | --- | +| Jugador `Desktop` o `Mobil` | `surface: player`, `projection: player-full`, perfil seleccionado y contrato personal. | Solo si es el mando activo de su asiento, con generación vigente. | +| Móvil mando `TVBoard` | `surface: player`, `projection: player-controller`, perfil `TVBoard` y contrato del mando. | Solo como mando activo del mismo asiento humano; no representa otro jugador. | +| TV de mesa presencial | `surface: shared-display`, `projection: public-board`, perfil `TVBoard`, `displayScope: room`. | No. | +| TV personal del invitado | `surface: shared-display`, `projection: public-board`, perfil `TVBoard`, `displayScope: participant`. | No; su entrega depende también de la membresía propietaria. | + +Después de una acción confirmada se distribuye la revisión por **suscripción autorizada y contrato**, no mediante un broadcast del estado completo a la sala. Desde la misma transición se construyen y validan las proyecciones necesarias: cada plaza recibe su vista/acciones propias y cada TV la vista pública. El recibo se dirige al principal que emitió el comando; la TV se actualiza por su snapshot, no recibe recibos personales ni manos privadas. Un agente virtual usa el servicio interno y su propia vista autorizada, sin pantalla, perfil visual o enlace de mando. + +Estado, recibo y outbox se confirman juntos. La outbox conserva las salidas autorizadas de esa revisión, sus contratos y destinos internos, sin reconstruir después un estado nuevo etiquetado con una revisión antigua. Antes de entregar se revalidan permisos, generación del vínculo, identidad de la suscripción y contrato seleccionado. Un cambio de perfil cierra la suscripción anterior y crea otra mediante la barrera de sincronización: las entregas pendientes del contrato anterior se descartan para esa suscripción y el cliente recibe un snapshot actual del nuevo contrato. No mezcla eventos del mando con los de vista completa ni reproduce secretos desde caché. + +La autorización final de entrega aplica también el mando activo y la fuente de estímulo fijada por ronda. Las ofertas de reglas son necesarias pero no suficientes: solo la conexión con `context.control.canSend: true` recibe ofertas ejecutables. Otras conexiones del mismo principal conservan consultas de recibos y lecturas permitidas, sin robar el mando con `game.sync`. Durante reacción no reciben el estímulo por una ruta alternativa a la fuente congelada; si no existe una proyección segura, se rechaza esa suscripción hasta el punto permitido. Cambios de mando/fuente invalidan entregas pendientes y exigen una barrera nueva, no reetiquetar una salida antigua con otra generación. + +Las referencias de evento son estables por proyección/destinatario/contrato y revisión, con orden local; nunca se revela el índice interno de un evento oculto. Todas las suscripciones de una partida comparten la revisión autoritativa y detectan huecos, aunque reciban contenidos distintos. El cliente valida `subscriptionId`, perfil, proyección y versión antes de aplicar un mensaje. La identidad de recibos sigue siendo partida + principal autorizado + `commandId`, independiente de móvil, TV, perfil o conexión: cambiar presentación o reconectar no repite una jugada ya confirmada. + +La revisión global y el ritmo de entrega pueden revelar **que hubo actividad**, aunque no su contenido. Este perfil no promete ocultar esos metadatos. Un juego cuyas reglas requieran ocultar incluso la existencia o el número de acciones necesita otro contrato probado de cursores/agrupación por proyección antes de publicarse; borrar campos privados por sí solo no cumple esa garantía. + +Los mensajes de preparación/QR pertenecen al protocolo de plataforma. El QR de altas solo puede generar membresías por la ruta `screen-qr`; el QR del mando personal solo continúa una admisión convencional y vincula dispositivos. Ninguno es un `game.command`, una oferta del motor ni permiso para suscribirse a otra proyección. Las rutas/bindings y esquemas v2 deberán materializar estas distinciones; el protocolo v1 permanece sin cambios. + +`game.sync.client` declara además `clientRuntime` y, en cada tupla, `rendererId`, `rendererVersion` y `rendererApiVersion`. El servidor comprueba esa implementación publicada antes de entregar vista y devuelve la tupla aceptada. Web y Flutter reciben el mismo contrato semántico y envían `game.command` por la conexión autenticada al servidor; los controles no envían mensajes directamente a la TV. La [comunicación bidireccional](../platform/client-communication-profile.md#5-acciones-e-información-en-ambos-sentidos) concreta el envío, recibo, actualización y recuperación por UUID. Una app sin renderer compatible no obtiene datos para improvisar otra presentación. + +El [contrato temporal v2 propuesto](synchronization-profile.md) añade sincronización de reloj y rondas a esta distribución. Todos los destinos comparten `clockEpoch`, `roundId`, generación y tiempos autoritativos, pero reciben solo el contenido de su proyección y fuente autorizada. `game.snapshot.context.timing` representa el estado actual; `round.*` son eventos de ese snapshot y `time.*` mensajes técnicos. Preparación, revelación, recogida y resolución son fases distintas; el orden de llegada de un paquete o de un commit no define por sí solo quién respondió primero. Los acuses de reloj/preparación de una TV no le conceden acciones de juego. Una transición de perfil conserva el reloj de partida y cambia la suscripción, no la identidad del comando. En una ronda temporizada sin resolver prevalece la política de incidentes temporales sobre `sharedDisplay.onLoss`; no se continúa una carrera sin su fuente fijada. + +## 5. Vinculación de pantalla a sala + +El alta de pantalla es un flujo de **plataforma**, no una acción de juego. Una TV sin cuenta abre una página HTTPS de emparejamiento y obtiene un desafío de un solo uso, caducidad corta y código legible/QR. No recibe `roomId`, nombres ni partida antes de vincularse. Un anfitrión o persona con permiso explícito introduce o escanea el código desde su sesión de jugador, elige la sala y confirma el vínculo. El servidor crea una sesión de pantalla limitada a esa sala y la TV muestra un estado de conexión reconocible. Código y QR no contienen credenciales permanentes; se limitan intentos, se excluyen de logs y el desafío se consume atómicamente. + +Las [rutas propuestas](../platform/api-and-events.md#pantallas-compartidas-extensión-futura-del-perfil-multidispositivo) concretan desafío, consulta, vinculación, listado y revocación. Todas las mutaciones aplican idempotencia y revalidación de permisos; el adaptador web exige origen/CSRF y el nativo su sesión emitida por plataforma, según el [perfil de comunicación](../platform/client-communication-profile.md). La ausencia de `Origin` no autoriza cookies ni convierte un navegador en app. La sesión de pantalla no se convierte en cuenta, invitación, membresía, presencia, chat ni dispositivo de administración. Al cerrar sala, caducar el vínculo o perder la autorización responsable en una sala protegida se anula su acceso; el código de emparejamiento no puede reusarse. La política propuesta limita a una pantalla común por sala y una pantalla personal por membresía humana en modos compatibles, con cuotas globales propias; las suscripciones conservan el mismo alcance de lectura restringido. + +La TV no debe almacenar la sesión para partidas futuras. La pantalla de espera, error o reconexión elimina datos de la sala tras revocación y no expone información privada a través de notificaciones del navegador, URL, caché o historial. En salas protegidas la proyección omite alias/avatares identificables, chat, perfiles y texto libre; el anfitrión responsable debe iniciar expresamente la pantalla. Estas condiciones se comprueban en servidor y en revisión de publicación de cada juego, no solo con CSS. + +Una pantalla personal requiere petición expresa del jugador propietario y, en sala protegida, autorización vigente del responsable. La retirada, pérdida de acceso o cambio de identidad de ese jugador revoca su TV, además de los motivos generales. Vincular una TV personal no permite gestionar la pantalla común ni la mesa; el servidor deriva la membresía de quien confirma, sin aceptar un asiento elegido por la pantalla. + +### Creación y entrada por QR en TVBoard + +En una mesa presencial con `entryMethod: screen-qr`, el QR visible en la TV identifica **el juego/versiones elegidos y una mesa concreta**, no una ficha genérica del catálogo. Abrirlo lleva al flujo de entrada y después al mando `TVBoard`. No se crean registros `Invitación`, no se envían invitaciones individuales ni se habilitan sus operaciones en esa mesa. `entryMethod` es distinto de `admissionMode: individual|adult|protected`: escanear no acredita identidad, tutela o permiso para jugar. + +La selección puede empezar en la TV con contenido público apto: elegir juego y `TVBoard` prepara un contexto temporal sin sala activa, membresías ni autoridad de juego. La TV muestra un QR de preparación con esa selección. Una persona habilitada lo escanea, se autentica si procede y confirma expresamente crear/gestionar la mesa y su política. El servidor crea la sala `waiting`, su membresía de anfitrión, el vínculo de pantalla y el acceso QR de jugadores en una transacción. En salas protegidas esa persona debe ser el responsable adulto autorizado. Conectarse primero no concede anfitrión sin esta confirmación y comprobación de permisos. + +Una vez creada, la TV sustituye el QR de preparación por el **QR de entrada a la mesa**. Las demás personas lo escanean y solicitan plaza mediante el flujo específico de QR, con sesión y comprobaciones actuales. La preparación no consume plazas, no recibe estado privado y caduca si nadie autorizado confirma. También puede prepararse la mesa desde el móvil del anfitrión y vincular después la TV; la entrada de los demás sigue siendo el QR visible en pantalla. Son fases del mismo flujo de producto, no invitaciones. + +El acceso QR usa un token opaco de finalidad `tvboard-entry`, impredecible, con revisión/generación, caducidad, revocación y límites de intentos/uso. Está ligado a una sala, al contexto de pantalla autorizado y a su paquete; no reutiliza el desafío de vinculación de un solo uso ni tokens de cuenta/invitación. La ruta propuesta es `/join#tvboard=TOKEN`; el token se pasa a memoria y por POST, con `no-store`, `no-referrer` y cuerpos excluidos de logs/analítica. La ficha de resolución presenta solo el juego público apto y requisitos de entrada permitidos, sin participantes ni secretos. El QR contiene una referencia verificable; el juego anunciado lo determina el servidor, no datos editables de la URL. + +El QR de entrada admite a varias personas durante la espera, hasta las plazas humanas disponibles, descontando las virtuales configuradas. Cada alta, consumo de uso, validación de token/generación, capacidad, bloqueos, protección y estado se confirma bajo la misma unidad de trabajo. Reintentar desde un miembro actual devuelve su membresía sin ocupar otra plaza. Se proponen vencimiento máximo de 5 minutos y rotación autorizada al caducar; el valor final y cuotas deben fijarse en los contratos ejecutables. Mostrar el QR requiere una copia cifrada recuperable solo por el contexto autorizado mientras siga vigente; guardar únicamente el hash no permite volver a representarlo. + +La mesa se crea privada, fuera del listado de descubrimiento. Su QR queda inutilizable para nuevas altas al iniciar, cerrar, revocar/expirar la pantalla o cambiar anfitrión; un QR nuevo necesita autorización vigente. Tras transferir anfitrión en espera se revalida el vínculo de TV y se rota el acceso bajo bloqueo. Quien ya participa recupera su plaza por sesión/membresía, sin necesitar un QR válido ni recibir otra invitación. Una revancha prepara otra mesa y otro QR, con entrada explícita; no incorpora automáticamente a participantes anteriores. + +El anfitrión confirma capacidad y opciones; cada persona confirma «listo» en el móvil y la plataforma inicia solo al cumplir las condiciones del juego, la protección y la pantalla requerida. La TV muestra el juego, QR y progreso agregado apto de preparación; durante la partida recibe únicamente la proyección pública. Un agente virtual ocupa su plaza desde servidor y no escanea un QR. + +### Acceso con invitación desde Smart TV y móvil como mando + +Un jugador puede abrir la plataforma en su Smart TV e introducir la invitación recibida para una sala convencional `standard` cuyo juego/modo permita `TVBoard`. La plataforma resuelve una ficha apta y prepara un enlace temporal entre ese contexto de TV y el futuro mando. Introducir la invitación no crea otra mesa, no ocupa otra plaza y no autentica una cuenta en la TV. + +La TV muestra un **QR personal de enlace del mando**, distinto del QR de altas de una mesa presencial. El móvil lo escanea, recupera el flujo pendiente, autentica al jugador si procede y le pide confirmar el acceso y el uso de esa pantalla. El servidor revalida/consume la invitación al admitirlo, crea o recupera una sola membresía y vincula la TV a ella en una transacción. Una invitación agotada o revocada no se renueva por preparar el QR; los reintentos recuperan el mismo recibo/membresía y no consumen usos adicionales. + +El desafío de enlace es de un solo uso, de caducidad corta y ligado al contexto que lo emitió, con finalidad separada del acceso `tvboard-entry`. No contiene credenciales humanas ni el token de invitación en claro: el servidor conserva el contexto pendiente cifrado con retención limitada. El QR permite continuar ese flujo tras autenticar y confirmar, no concede acceso a otra persona por sí solo. Se aplican fragmento de URL, POST, autenticación según adaptador (CSRF/origen en web), límites, `no-store`, ausencia de terceros y recuperación cifrada acotada igual que en la vinculación general. + +Tras confirmar, la TV recibe la proyección pública del estado y desarrollo del juego; el móvil carga los controles de cada etapa y la información privada del asiento. Todos los comandos parten del contexto autenticado del móvil. La pantalla mantiene `surface: shared-display`, `presentationProfile: TVBoard`, `displayScope: participant`, sin cuenta, acciones ni rol de anfitrión. Otros participantes de esa misma sala pueden usar `Desktop`, `Mobil` o su propia TV si el paquete lo declara. Una segunda pantalla personal reemplaza la anterior mediante confirmación autorizada, sin duplicar plaza ni conservar dos vínculos ocultos. + +Un fallo de vinculación revierte la admisión coordinada si aún no se confirmó; si el cliente perdió la respuesta después del commit, consulta/reintenta el mismo comando. Para un miembro ya admitido el enlace solo vincula su TV y no vuelve a consumir la invitación. Al reconectar se recupera la misma plaza por su sesión y la pantalla por su contexto acotado, sin necesitar otra invitación. El QR del mando se inutiliza al consumirse o caducar; se revalidan permisos también al ejecutar tareas y entregar vistas. La protección, incluida la aprobación del responsable para la TV personal, se mantiene en este recorrido. + +## 6. Móvil como mando y sensores + +En un modo de mesa, la superficie personal funciona como mando y espacio privado. Su presentación pertenece al paquete de juego y adapta controles a fase, rol y ofertas confirmadas: espera, selección, turno, acción pendiente y resultado. Las etiquetas describen acciones («Jugar carta», «Elegir columna», «Lanzar dado»), con áreas táctiles amplias y alternativas accesibles. La TV muestra el estado público a distancia; cartas y roles secretos solo se entregan al asiento autorizado. La dirección estética sigue pendiente. + +Una pulsación puede recibir feedback local inmediato y mostrar «pendiente», pero el tablero compartido solo cambia con la transición confirmada. Cambiar la vista del mando no crea permisos: las acciones proceden de las ofertas del servidor. Una vista confirmada identifica a qué plaza corresponde la sesión, con alias/presets seguros según protección. Perder conexión, bloquear el teléfono o volver al navegador exige resincronización antes de admitir una nueva acción; no reorganiza asientos ni añade un participante nuevo. + +`motion.shake` es un detector local de intención mientras una oferta compatible está visible y la persona ha activado «Agitar para lanzar». La aplicación solicita permiso **solo tras esa acción**, usa HTTPS y comprueba soporte real. [Device Orientation and Motion del W3C](https://www.w3.org/TR/orientation-event/) define contexto seguro, permiso explícito y dependencia de una activación del usuario; la compatibilidad concreta sigue variando. La medición se detiene al salir de la vista, perder foco, terminar el turno o revocar permiso. No se envían ni conservan muestras crudas de acelerómetro/giroscopio. + +El adaptador de plataforma aplica umbral calibrado y acotado, histéresis, una activación por oferta, enfriamiento y rechazo de eventos cuando la app está oculta. Una sacudida accidental no debe lanzar dos comandos. El gesto puede producir una animación local del cubilete, pero el dado que se muestra al final procede del recibo/estado confirmado por servidor. Siempre hay un botón y operación de teclado equivalentes; se respeta movimiento reducido, y háptica/sonido son opcionales. Un juego de habilidad basado en sensores requeriría reglas y garantías propias, no se infiere de este perfil de dados. + +## 7. Compatibilidad y conformidad + +### Gráficos y requisitos por juego + +**El alcance decidido por el usuario es 2D, incluida perspectiva, con soporte de sprites y animaciones sencillas; no se contempla un motor 3D ni Three.js, iluminación o efectos gráficos avanzados. WebGL no es un requisito global de la plataforma ni del protocolo.** Tras considerar Phaser, el usuario cuestiona su peso y pide comparar Konva con Motion y su biblioteca propia de `vicen`: la prioridad es reducir dependencias y consumo, conservando imágenes/sprites, capas, reproducción por fotogramas, movimientos y secuencias coordinadas. Antes de incorporar otra dependencia, evaluar las capacidades ya disponibles en el motor propio; **Konva queda como candidato condicionado a necesitar dibujo y gestión de objetos Canvas**, y Phaser como alternativa de mayor alcance. Esta evaluación no constituye una selección o integración aprobada. HTML/SVG/CSS pueden representar el tablero junto con una capa de animación/sprites; no se exige Canvas por ser un juego. La demo aislada de PixiJS no aprueba dependencias ni demuestra soporte en TV. + +**Inspección del código de `vicen`, 6 de octubre:** `src/libs/motion` contiene tipos y resolución de preferencias (`allow`/`reduce`/`system`); el motor está en `src/arts/motion`. `EngineMotion` registra presets y ejecuta sobre `HTMLElement` los drivers `waapi`, `spring` y `rect` (FLIP), con handles `finished`, cancelación/`AbortSignal`, traspaso de posición/velocidad entre muelles y preferencia reducida leída por ejecución. Los presets CSS dependen de generación/aplicación declarativa externa; no son animaciones ejecutadas por el motor. No se encontró un gestor dedicado de spritesheets, objetos/capas Canvas o timeline general en esos módulos. Las capacidades de [Motion para JavaScript](https://motion.dev/docs/animate) incluyen animación de HTML/SVG, valores/objetos y secuencias; animar valores de un objeto no implementa por sí mismo un renderer Canvas. Konva aporta ese renderer, su árbol de objetos/capas y detección de eventos dentro de Canvas, además de sprites por fotogramas. Para cartas/fichas DOM, empezar por una prueba con el motor propio y un controlador de fotogramas solo si se necesita; adaptar SVG explícitamente, ya que el contrato actual usa `HTMLElement`. Reutilizar requiere concretar exportación/aliases, puerto DOM y presets, sin asumir que todo UIX deba importarse. + +La revisión identifica una limitación temporal del driver propio `spring`: integra un paso fijo `1/60` por callback de `requestFrame` sin usar tiempo transcurrido; el puerto DOM existente delega en `requestAnimationFrame`. La animación puede cambiar de ritmo con la cadencia de pantalla o frames perdidos. **El usuario propone añadir una función nueva para no interferir en la actual:** se recomienda `springTimed(config: SpringConfig): MotionRun`, conservando `spring()` y los presets actuales. La nueva función usaría timestamps, acumulador y pasos físicos constantes con trabajo acotado; conservaría `finished`, cancelación/`AbortSignal`, movimiento reducido y traspaso de velocidad. Se registraría como preset de la familia `spring` con una función `MotionRun` distinta, sin cambiar por inferencia `EngineMotion` o el driver existente. Probar a 30/60/120 Hz, con jitter, interrupciones, cancelación e inversión, antes de una migración optativa. Para el juego, el coordinador recupera el estado confirmado tras una interrupción larga; el reloj visual no sustituye la autoridad temporal del servidor. **Es una propuesta pendiente, no una función implementada.** La inspección fue de solo lectura: no se modificó `vicen`, no se ejecutaron sus tests y no se probó el motor propio en TV/Cast. + +**Versiones aclaradas por el usuario y comprobadas el 6 de octubre de 2026:** **Phaser 4** es el motor; el [archivo oficial](https://phaser.io/download/archive) y una consulta directa al [registro npm](https://registry.npmjs.org/phaser) devuelven **4.2.1** como `latest`. [Phaser Editor 5](https://phaser.io/news/2026/04/phaser-editor-v5-release) es la herramienta de autoría visual compatible con ese motor. El editor se evalúa por separado como herramienta de trabajo: no es una dependencia obligatoria del juego, no cambia los requisitos del cliente publicado ni resuelve por sí mismo la compatibilidad Cast. Si se prueba Phaser, fijar una versión verificable de la v4; esta aclaración no modifica la prioridad de evaluar las capacidades propias existentes. + +| Capa propuesta | Responsabilidad | +| --- | --- | +| Presentación DOM, con el motor propio como candidato a reutilización | Svelte/HTML representa fichas/cartas/tablero; presets y drivers del motor animan elementos. Evaluar movimientos, cancelación/recuperación y controlador de fotogramas si hace falta. No se asume portabilidad automática de `vicen` ni se promete rendimiento sin prueba. | +| Biblioteca Canvas 2D opcional, con Konva como candidato | Si una escena justifica Canvas: capas/grupos, imágenes, sprites por fotogramas, transformaciones y tweens. La carga/organización de recursos y la coordinación con el juego se evalúan en la prueba; no se asume un sistema completo de paquetes de juego o importación automática de atlas. | +| Svelte + HTML/CSS/SVG | Estructura de la plataforma, controles, etiquetas, accesibilidad y elementos de interfaz; no necesita ejecutar un motor gráfico en cada mando. | +| Adaptador del coordinador propio de presentación | Traduce eventos confirmados a efectos de la biblioteca elegida, limita colas/duración, cancela y libera recursos al cambiar de generación; aplica movimiento reducido y recuperación. Reutiliza los sistemas de sprites/animación; conserva las barreras del protocolo, sin decidir reglas, azar, recibos o plazos de servidor. | + +La documentación oficial de Konva confirma [Canvas 2D, capas y transformaciones](https://konvajs.org/docs/overview.html), [sprites por fotogramas](https://konvajs.org/docs/shapes/Sprite.html) y [tweens](https://konvajs.org/docs/tweens/Linear_Easing.html). Si una escena justifica Canvas, permite evaluar estas funciones sin exigir WebGL ni añadir GSAP/PixiJS. Reutilizar los sistemas disponibles de dibujo y animación; no construir un renderer general por inferencia. El adaptador debe respetar los contratos del [coordinador visual](../game-engine-proposal.md#13-presentación-antes-durante-y-después): callbacks ligados a generación/`AbortSignal`, limpieza y asentamiento en la proyección vigente. No se reescriben ahora los contratos v1 ni se introducen objetos de la biblioteca en mensajes JSON. Go conserva reglas, validación, azar y sincronización autoritativos; la app Flutter sigue siendo solo de controles. + +**Ligereza es un criterio que se mide, no una garantía derivada de usar Canvas.** Comparar bundle de producción comprimido, tiempo hasta el primer dibujo, memoria, fluidez y actividad en reposo con las mismas escenas/recursos; separar el coste de imágenes del JavaScript. La primera prueba debe incluir tablero estático, movimiento/reparto de fichas o cartas, un sprite animado, perspectiva 2D, cancelación/recuperación y movimiento reducido. Revisar el [coste de capas y detección de eventos en Konva](https://konvajs.org/docs/performance/All_Performance_Tips.html): evitar capas innecesarias, desactivar escucha en el tablero público pasivo y detener animaciones cuando la vista esté estática u oculta. El soporte Canvas no acredita por sí solo compatibilidad con navegadores antiguos o dispositivos de TV/Cast; comprobar bundle, APIs y rendimiento en cada vía real. + +Phaser 4 permanece como alternativa si aparecen necesidades que justifiquen un motor más completo. Sus sistemas de [animaciones](https://docs.phaser.io/phaser/concepts/animations), [tweens](https://docs.phaser.io/phaser/concepts/tweens) y [escenas](https://docs.phaser.io/phaser/concepts/scenes) reúnen esas capacidades, pero no se prioriza su integración en el alcance sencillo actual. La [revisión oficial de su renderer](https://phaser.io/news/2026/04/phaser-4-renderer-faster-cleaner-and-built-for-modern-games) indica que Canvas se conserva **deprecado** y que las funciones gráficas nuevas dependen de WebGL. Si se vuelve a evaluar para Cast directo, probar expresamente sprites, texto, orden de capas, transformaciones y tweens sin WebGL; no prometer equivalencia de filtros/shaders ni soporte futuro de Canvas por existir hoy. + +La perspectiva visual se resuelve en la presentación 2D: disposición isométrica o de mesa, escala, orden de superposición, sombras e imágenes/transformaciones de fichas y cartas. No implica cámaras, geometría o iluminación de un motor 3D. Las coordenadas/identificadores lógicos del tablero y las acciones semánticas siguen siendo independientes de su dibujo; si hay interacción sobre una vista transformada, el renderer convierte la selección a la casilla/acción correspondiente. La perspectiva no cambia qué información está autorizada a mostrar cada proyección. + +Cada renderer debe publicar sus requisitos gráficos y ofrecer únicamente combinaciones comprobadas. Un juego que necesite WebGL puede ofrecer una presentación equivalente sin WebGL si la implementa y prueba. Se comprueban el bundle/versiones y las funciones concretas antes de prometer fallback: atlas, sprites animados, texto, máscaras, perspectiva y secuencias, sin asumir que filtros/shaders tienen equivalente. No hay conversión automática a SVG. Cambiar renderer conserva proyección, reglas, resultados y barreras temporales. Un fallo de contexto gráfico requiere recuperación o alternativa explícita, sin dejar una pantalla obsoleta aparentando estar en directo. Estos requisitos/alternativas necesitan esquemas y negociación antes de habilitarse. + +### Televisores y dispositivos externos + +El usuario requiere contemplar **TV no smart con Chromecast, Fire TV u otro dispositivo conectado por HDMI**. La TV física puede ser solo la pantalla: el navegador, receptor Cast o aplicación del dispositivo conectado ejecuta el renderer y mantiene la conexión. Este eje de acceso es distinto de `presentationProfile`, `clientRuntime`, proyección y biblioteca gráfica. Elegirlo no crea otra plaza, cambia el modo de sala ni convierte un enlace personal en entrada de mesa. + +| Vía de acceso prevista | Cliente que ejecuta la pantalla | Trabajo pendiente | +| --- | --- | --- | +| Navegador de Smart TV | Web `public-board` en el motor del modelo concreto. | Compatibilidad, navegación con mando, QR, memoria, suspensión y reconexión. | +| Fire TV con Silk | Web `public-board` en Silk; Amazon documenta [Silk para Fire TV](https://docs.aws.amazon.com/silk/latest/developerguide/what-is-silk.html). | Prueba por modelo/versión; una app de TV posterior es una entrega distinta. | +| Google TV / Android TV, incluido Chromecast con Google TV | Aplicación de TV futura con renderer compatible; navegador solo donde se verifique. | Instalación, mando/foco, ciclo de vida y adaptación de sesión de pantalla. No es la app Flutter de controles. | +| Chromecast clásico mediante Google Cast | [Custom Web Receiver](https://developers.google.com/cast/docs/web_receiver/basic) HTML5 propio, alojado por HTTPS y registrado con App ID, iniciado por un emisor compatible. | Renderer sin WebGL, SDK emisor/receptor, registro, vinculación y pruebas reales. No basta enviar la URL de la plataforma a un receptor multimedia genérico. | +| Duplicación de una pestaña pública hacia Chromecast | El navegador de origen ejecuta `public-board`; el dispositivo presenta su imagen. | Compatibilidad y demora de la ruta completa; solo duplicar la página dedicada de tablero público. | +| Ordenador conectado por HDMI | Web `public-board` en el navegador del ordenador. | Pantalla completa, audio, resolución y recuperación en equipos reales. | + +El receptor Cast inicia su propio contexto de preparación/vinculación de pantalla y muestra el QR/código correspondiente. El emisor permite lanzarlo y continuar la confirmación autorizada; no reenvía cookies/tokens de cuenta, vistas privadas o comandos de juego. Tras vincular, el receptor obtiene directamente del servidor Go su `public-board` por el binding HTTPS/WSS restringido; el canal Cast se limita al lanzamiento/vinculación y gestión de esa pantalla. App ID, descubrimiento o pertenecer a la misma WiFi no acreditan permisos de sala. Las rutas y el adaptador de sesión/Origin del receptor siguen pendientes; se conserva la restricción de que omitir `Origin` no autoriza una cookie web. + +La [guía oficial de restricciones de Google Cast](https://developers.google.com/cast/docs/ux_guidelines#considerations), consultada el 6 de octubre de 2026 y actualizada el 24 de octubre de 2024, indica que el **Web Receiver no admite WebGL**. Por tanto, esa vía exige una presentación comprobada sin WebGL, aunque el juego sea 2D. Duplicar una pestaña es distinto: el renderer se ejecuta en el ordenador que genera la imagen y el Chromecast la presenta, con la demora de duplicación. Una app instalada en Google TV/Android TV tiene otro entorno, cuyo soporte gráfico se verifica por separado; no hereda una garantía por compartir dispositivo con Cast. + +La [documentación del emisor web de Google](https://developers.google.com/cast/docs/web_sender) distingue navegadores compatibles y SDK nativos; Chrome en iOS no admite ese envío web. Se comprueba cada vía y se ofrece una alternativa compatible, sin suponer que un botón Cast web funciona en todos los móviles. Finalizar o sustituir la sesión Cast, suspender el dispositivo y perder el emisor se tratan como eventos de ciclo de vida de pantalla; no equivalen por sí solos a retirar al jugador. La pérdida de la fuente visual aplica la política del modo y de la ronda temporal. Al cambiar a `Mobil` se revoca también el acceso de su receptor personal. + +La matriz de soporte incluye modelo, sistema, navegador/app/receptor y versión, renderer, memoria, resolución, audio, gráficos y HTTPS/WSS. [Samsung](https://developer.samsung.com/smarttv/develop/specifications/web-engine-specifications.html) y [LG webOS](https://webostv.developer.lge.com/develop/specifications/web-api-and-web-engine) documentan motores distintos según modelo/año. No se promete un mismo bundle para todos. Sin WebSocket compatible se ofrece un fallback de lectura con sincronización acotada **solo si** se diseña y prueba. Primero se propone verificar web/HDMI y Silk; receptor Cast y apps de TV se habilitan en cortes específicos. Ninguna de estas vías está implementada o certificada hoy. + +### Pruebas de conformidad + +Para aceptar un juego en este perfil se prueban: privacidad de `sharedView` y de todos sus eventos/errores; cero ofertas y comandos desde TV; autorización de vinculación/revocación con carreras y reconexión; sala protegida; varias pestañas/dispositivos del jugador; pérdida y retorno de pantalla; gesto denegado/ausente; botón alternativo; duplicados/idempotencia; resultados de azar iguales entre modalidades; idioma, sonido y movimiento reducido en todas las presentaciones. Se comprueba `TVBoard` con sus dos entradas, `Desktop`/`Mobil` con estado y controles completos, selección/cambio de perfil sin cambiar permisos ni perder comandos, y rechazo de combinaciones no declaradas. El verificador de paquetes debe rechazar soporte sin funciones, esquemas, presentación, recursos, traducciones y pruebas correspondientes. + +La entrada `TVBoard` necesita además fixtures de preparación sin cuenta/sala, confirmación autorizada del anfitrión, creación/vinculación/QR atómicas, dos escaneos que compiten por la última plaza, duplicados, caducidad/rotación, tokens de otra finalidad, QR de otro juego/paquete, revocación concurrente e inicio que cierra altas. En una mesa con QR, crear o consumir una invitación se rechaza incluso por llamada directa a API. Se comprueba que QR, errores y estados de preparación no revelen personas ni contenido privado. + +El recorrido de invitación desde Smart TV se prueba aparte: preparación sin consumir usos, confirmación móvil y consumo/vínculo atómicos, invitación que caduca o se revoca entre escaneo y confirmación, dos móviles que intentan consumir el mismo desafío, miembro ya admitido, respuesta perdida tras commit, varias TVs personales con aislamiento por membresía, cambio de cuenta/retirada/revocación, protección y fallback personal a `Mobil`. El QR de entrada de mesa y el de enlace del mando no se aceptan en rutas de otra finalidad. + +También se prueba «Jugar solo en el móvil» por elección voluntaria con TV funcionando: nueva proyección completa, revocación de su TV, recibo pendiente recuperado y ausencia de otra plaza/uso de invitación. Un paquete que permita el acceso convencional por TV personal sin `Mobil` se rechaza. En juegos de reacción se comprueba la solicitud pendiente entre rondas, el reloj sin reinicio y la salida neutral ante pérdida de la fuente temporal. + +La conformidad compara mando web y futuro Flutter: mismos contratos de `player-controller`, acciones y resultados, controles declarados, QR con/sin app, sesiones propias revocables, suspensión/reconexión y aislamiento por cuenta. Comprueba rechazo de Flutter con `player-full`/`public-board` y transferencia a `Mobil` web conservando plaza/recibos. No se presupone ejecutar el bundle web en nativo. Los SDK TypeScript/Dart comparten fixtures; una combinación Flutter ausente ofrece solo una alternativa web autorizada y comprobada. + +Las pruebas de distribución comparan la misma revisión entre `player-full`, `player-controller` y `public-board`, con fixtures de esquemas y privacidad por destinatario. Se verifica que una acción privada no se filtre en otra TV/mando, que un recibo no llegue a pantalla, que mensajes tardíos de un perfil anterior se descarten y que duplicados/reconexión/cambio de dispositivo mantengan un solo efecto. Revocar el vínculo personal de una TV corta únicamente sus entregas autorizadas; revocar la membresía corta también su mando y todas las vistas que dependían de ella. + +Se añade al futuro entorno de desarrollo un simulador de una pantalla común y varios mandos, con identidades de prueba separadas. Sirve para comprobar vistas, privacidad, fases y reconexión; la conformidad requiere también teléfonos y pantallas reales. Se ensayan red lenta/inestable, bloqueo y retorno del móvil, recarga de TV, dispositivos extra sin plaza, intentos de menú sin permisos y recuperación sin repetir movimientos. La UI explica cuántas plazas faltan y por qué un dispositivo no puede jugar, sin mostrar datos identificables en una sala protegida. + +## 8. Referencia de interacción: AirConsole + +El usuario fija [AirConsole](https://www.airconsole.com/) como referente para usar la TV como tablero y los móviles como mandos. Revisión de documentación oficial: **6 de octubre de 2026**. Es una referencia funcional para el perfil propio; no implica integrar su SDK, adoptar su alojamiento o aprobar una identidad visual. No se ha probado una sesión de su juego ni verificado compatibilidad de nuestra implementación, que sigue pendiente. + +Su guía actual de [juego en TV](https://airconsole.zendesk.com/hc/en-us/articles/4405279964946-Play-on-TV) recomienda la app para Android TV, Google TV y Fire TV, contempla PC por HDMI y no garantiza los navegadores integrados de Smart TV. Su [guía de Chromecast](https://airconsole.zendesk.com/hc/en-us/articles/360015065059-Chromecast), actualizada en 2021, separa duplicación desde PC en modelos antiguos de app instalada en modelos con tienda. Es una referencia para distinguir vías, no evidencia de que AirConsole use un receptor Cast propio ni una medida de latencia válida para nuestros equipos. + +Su [explicación del producto](https://www.airconsole.com/info) presenta una pantalla compartida, un móvil por jugador y vinculación mediante código. Indica que los dispositivos no necesitan la misma WiFi. Para `games2`, ese patrón requiere acceso HTTPS al servidor, autorización y pruebas de conectividad; no significa que podamos prometer juego sin Internet o soporte de todos los navegadores de TV. + +| Referencia publicada | Aplicación al perfil propio | +| --- | --- | +| Las [guías del mando](https://developers.airconsole.com/smartphones-as-controllers) recomiendan controles grandes, etiquetas por función, vistas según situación e información individual. | La presentación personal del juego adapta las acciones vigentes a móvil y puede mostrar la mano privada; la TV recibe la proyección común. Se prototipa esta interacción desde el primer juego multidispositivo. | +| El [inicio rápido](https://developers.airconsole.com/quick-start) separa `screen.html` y `controller.html` y ejemplifica mensajes entre dispositivos. | Conservamos entradas de presentación diferenciadas dentro del paquete. Los móviles envían comandos al servidor Go y la TV recibe vistas confirmadas; las reglas no se trasladan al navegador de la pantalla. | +| La [guía de identidades y estado](https://developers.airconsole.com/device-ids-and-state) distingue identificadores de dispositivo y número de jugador, y advierte que los estados personalizados son legibles por otros dispositivos. | Sesión, dispositivo, membresía y asiento son conceptos separados. Se recupera el asiento autenticado tras un corte. Manos, roles y decisiones privadas nunca viajan por un estado difundido a todos. | +| Su [checklist](https://developers.airconsole.com/airconsole-checklist) incluye latencia, conexiones/desconexiones, límites de jugadores, permisos de menú y controles adaptados. | Se amplían los escenarios de aceptación sin copiar sus mínimos de navegador o sus políticas comerciales. Menús y configuración usan permisos del anfitrión; el primer móvil conectado no obtiene ese rol por orden de llegada. | +| Su [simulador](https://developers.airconsole.com/testing-your-game) permite probar pantalla y mandos en una ventana y añadir teléfonos reales. | Se propone un simulador propio con cuentas sintéticas, más verificación real. Una URL `localhost` en el teléfono apunta al propio teléfono: las pruebas multidispositivo requieren un origen HTTPS accesible y autorizado. | + +Flujo previsto de `games2`, aplicando las autorizaciones ya definidas: + +1. Para crear una mesa presencial, la TV permite elegir juego público y `TVBoard` y muestra el QR de preparación, sin datos privados de partida. +2. El anfitrión autorizado confirma desde el móvil la creación de una mesa `TVBoard` para el juego seleccionado y su vínculo de TV. Modo y entrada `screen-qr` quedan fijados; la pantalla obtiene el QR de entrada de esa mesa. +3. Las demás personas escanean el QR de juego/mesa, pasan las comprobaciones de admisión, reciben su plaza y confirman «listo». No se envían ni crean invitaciones. El desafío de vinculación de TV no se reutiliza como acceso de jugadores. +4. La TV muestra tablero, estado y eventos públicos. Cada móvil muestra su información privada y controles contextuales; un agente virtual admitido actúa desde su instancia en servidor sin necesitar móvil. +5. Ante cortes, cada cliente recupera su proyección actual y los comandos conservan recibos/idempotencia. Una TV revocada vuelve a una pantalla sin datos. + +En el recorrido alternativo, una persona introduce su invitación en la Smart TV y escanea el QR personal desde el móvil. La sala conserva su entrada convencional; se vinculan exclusivamente la pantalla y el mando de su plaza, sin activar el QR de altas de una mesa presencial. + +La división pública/privada se define por juego: Conecta 4 muestra el tablero común y selección de columna en el móvil; Brisca muestra la mesa pública y conserva la mano en el mando. En Hundido solo se comparte lo que autoricen sus reglas, nunca posiciones ocultas; la existencia de una TV no convierte toda la partida en información pública. Estos ejemplos son criterios del perfil propuesto, no juegos ya instalados. diff --git a/docs/game-engine/synchronization-profile.md b/docs/game-engine/synchronization-profile.md new file mode 100644 index 0000000..d3791d8 --- /dev/null +++ b/docs/game-engine/synchronization-profile.md @@ -0,0 +1,149 @@ +# Sincronización temporal y equidad de respuestas — propuesta v2 + +Estado: investigación y diseño, **6 de octubre de 2026**. Complementa el [perfil multidispositivo](multi-device-profile.md), el [protocolo del motor](format-and-protocol.md) y la [API de plataforma](../platform/api-and-events.md). No existen todavía esquemas, mensajes ejecutables ni runtime para este perfil. Los nombres y límites siguientes son propuestas propias; no describen una integración con los productos citados. + +## 1. Qué se debe garantizar + +El servidor mantiene un único estado confirmado, plazos y resultado. Cada juego declara cómo importa el tiempo: por turnos, respuestas dentro de una ventana o reacción. La distribución por proyección y la sincronización de reloj son problemas distintos. Recibir la misma revisión no significa ver la pregunta en el mismo instante. + +Para «el primero que contesta» no se ordenan respuestas únicamente por llegada al servidor, adquisición del bloqueo SQL o confirmación. Esos órdenes incluyen transporte y carga de servidor. Tampoco se acepta como autoridad un `clickedAt`, un ping o un cronómetro enviado por el navegador. + +**Límite físico:** una conexión de Internet y un navegador no permiten comprobar exactamente cuándo se mostraron los píxeles ni cuándo pulsó una persona. RTT no revela por separado ida y vuelta; las rutas pueden ser asimétricas. El retardo de renderizado y de la TV también importa. Compensar reduce sesgo bajo condiciones medidas; no acredita igualdad perfecta ni permite compensar un corte arbitrariamente largo. Esta conclusión es nuestra inferencia a partir del modelo temporal de [NTP, RFC 5905](https://www.rfc-editor.org/rfc/rfc5905.html#section-8) y las limitaciones que explican [Riot](https://www.riotgames.com/en/news/peeking-valorants-netcode) y [Photon](https://doc.photonengine.com/fusion/v2/manual/advanced/lag-compensation). + +El requisito se concreta así: no otorgar una ventaja por el mero orden de transporte; admitir empate cuando la medición no permite distinguir; y neutralizar una ronda afectada por un fallo temporal fuera del presupuesto. Si se exige que la velocidad de red no pueda decidir el resultado, se usa acierto sin clasificación por velocidad y, cuando haga falta, sin tiempo límite. Un ranking exacto de reacción bajo cualquier lag no es una garantía publicable. + +## 2. Técnicas publicadas por la industria + +Fuentes primarias consultadas en la fecha indicada. Los artículos históricos describen técnicas; su fecha no acredita la configuración actual de un juego comercial. + +La guía enlazada de Unity corresponde a 2.4.2 y avisa que ese sitio ya no se actualiza; se usa para explicar el modelo, no para elegir una versión actual del SDK. Remite a la [documentación oficial del paquete](https://docs.unity3d.com/Packages/com.unity.netcode.gameobjects@latest/?subfolder=/manual/index.html). La página de reloj de Photon Fusion Core consultada indica actualización del 19 de agosto de 2026. + +| Técnica y fuente | Qué resuelve | Aplicación propuesta aquí | +| --- | --- | --- | +| Reloj de red y ticks: [Unity](https://mp-docs.dl.it.unity3d.com/netcode/current/advanced-topics/networktime-ticks/) y [Photon Fusion Core](https://doc.photonengine.com/fusion-core/v3/manual/time). | Estiman tiempo de servidor y coordinan eventos; los ticks discretizan simulación/transmisión. | Cuenta atrás y plazos comunes; estimación con calidad y época. Un tick no prueba el instante real de pulsación. | +| Predicción y reconciliación: [Riot, netcode de VALORANT](https://www.riotgames.com/en/news/peeking-valorants-netcode). | El cliente responde visualmente antes de recibir confirmación y corrige diferencias con la autoridad. | Feedback local de pulsación y estado pendiente; ganador, acierto y puntuación esperan al servidor. | +| Buffer e interpolación: [Riot](https://www.riotgames.com/en/news/peeking-valorants-netcode). | Suavizan llegadas irregulares, a costa de añadir demora. | Animación pública suave; los controles no esperan a una animación. Presupuesto separado de renderizado y transporte. | +| Compensación con historial: [Photon Fusion, lag compensation](https://doc.photonengine.com/fusion/v2/manual/advanced/lag-compensation). | Consulta posiciones pasadas para juzgar una acción respecto a la vista del atacante; tiene consecuencias para otros jugadores. | Inspiración para conservar contexto temporal. Rebobinar hitboxes no resuelve directamente quién respondió antes a una pregunta. | +| Lockstep con espera y rollback: [GGPO, documentación del SDK](https://github.com/pond3r/ggpo/blob/master/doc/README.md). | Esperar inputs introduce demora; predicción, restauración y resimulación la ocultan en juegos deterministas. | Perfil futuro para acción continua. Una respuesta desconocida no se predice y un resultado anunciado no se revierte como una animación. | +| Cambiar la regla de puntuación: [Kahoot, cálculo de puntos](https://support.kahoot.com/hc/es/articles/115002303908-C%C3%B3mo-funcionan-los-puntos). | Publica puntuación por tiempo y un modo de precisión sin efecto de velocidad. | Ofrecer acierto dentro de ventana o sin reloj cuando la equidad importa más que ordenar milisegundos. No atribuimos a Kahoot un protocolo de compensación no documentado. | + +La reducción de latencia mediante ubicación de servidores, rendimiento y colas pequeñas complementa estas técnicas; Riot explica esos factores. No copiamos su frecuencia de simulación ni la convertimos en requisito para todos nuestros juegos. Un quiz necesita mensajes de ronda e inputs discretos; emitir 128 actualizaciones por segundo no elimina el trayecto de red. + +## 3. Propiedades temporales del juego + +La propiedad propuesta `synchronization` pertenece al paquete y se publica/versiona con las reglas. Cada modo temporal declara `interaction.modes[].synchronizationProfileId`, que debe referenciar un perfil existente; la sala fija ese perfil y sus límites antes de «listo». Omitir la extensión no concede mecánicas de reacción ni compensación por inferencia. El perfil no cambia automáticamente al medir ping. Un anfitrión no modifica retrospectivamente compensación, desempate ni puntuación. + +| Modelo propuesto | Regla temporal | +| --- | --- | +| `turn-based` | Orden por reglas/actor y revisiones. El lag no cambia un resultado de velocidad que el juego no tenga. Plazos expresos siguen el reloj de servidor. | +| `simultaneous-window` | Respuestas privadas independientes; se evalúan tras la recogida. Acierto sin ventaja por llegada. Permite ventana amplia o ausencia de límite. Es la base recomendada para el primer quiz. | +| `reaction-compensated` | Estimación temporal acotada, intervalo de incertidumbre y empate entre candidatos indistinguibles. Experimental hasta superar pruebas; no se anuncia como medición exacta. | +| `real-time` | Necesita contrato propio de tick, input, simulación, predicción/interpolación y posible rollback. Queda pendiente; no se habilita por declarar `TVBoard`. | + +Fragmento ilustrativo, no paquete instalable ni presupuesto aprobado: + +```json +{ + "synchronization": { + "profileVersion": 1, + "profiles": [{ + "id": "quiz-window", + "model": "simultaneous-window", + "scoring": "correctness", + "roundDurationMs": 10000, + "prepareTimeoutMs": 10000, + "collectionGraceMs": 250, + "presentationChange": "between-rounds", + "onTimingFailure": "void-round" + }] + } +} +``` + +Un perfil de reacción exige además versión del estimador, presupuesto máximo de compensación/incertidumbre, vigencia y mínimo de muestras, fuentes de estímulo admitidas, regla de empate y límite de reinicios. Se prueban valores por juego, infraestructura y dispositivos; no hay un ping universal que garantice equidad. Perfiles/localizaciones con distinta dificultad o duración de lectura requieren validación de contenido además de red. + +El perfil multidispositivo fija por jugador una fuente autorizada de estímulo: su vista completa, su TV personal o la TV común. `presentationProfile`, fuente, vínculo/generación y política temporal quedan registrados por ronda. Tener varias pestañas no multiplica fuentes, respuestas o presupuestos. No se asume que todos miran la misma pantalla porque usan `TVBoard`; una TV personal remota tiene otra ruta. + +En reacción, el servidor aplica esa selección a **todas** las suscripciones del asiento, incluidas nuevas pestañas, web/app simultáneas, reconexiones, snapshots y outbox pendientes. Las conexiones secundarias reciben solo una vista de espera/control sin el estímulo, o se rechaza su suscripción si el paquete carece de esa proyección segura. No se puede leer la pregunta en `player-full` y reclamar compensación por otra TV más lenta. Antes de entregar se revalida la generación de la fuente y del mando activo; cambiar de fuente exige el punto seguro de la sección 7. Esto limita rutas de nuestra plataforma, pero no acredita dónde mira una persona ni impide que un cliente manipulado retransmita contenido. + +## 4. Reloj, medición y autoridad + +Se proponen intercambios técnicos `time.probe`/`time.sample`, negociados en el binding v2 y permitidos también en TV pasiva. Llevan nonce de un solo uso, `clockEpoch` y marcas del mismo dominio temporal. Se acotan frecuencia, bytes y duración; no son comandos de juego, no incrementan revisiones y no contienen información personal. Un `serverTime` aislado de bienvenida sirve como referencia inicial, no como protocolo completo. + +En red, `serverTimeMs`, `startAtMs`, `answerDeadlineAtMs`, `collectUntilMs`, `ingressAtMs` y `stimulusReleaseAtMs` son enteros seguros de milisegundos transcurridos desde el origen de `clockEpoch`, no fechas Unix ni cadenas ISO. `c0`/`c3` usan milisegundos monotónicos del cliente; `s1`/`s2`, los del servidor en esa época. La estimación calcula su diferencia de origen. No se restan marcas de distintas épocas/conexiones tras una resincronización. Las fechas operativas duraderas y `issuedAt` de plataforma siguen siendo UTC con `Z`; el tiempo del reloj de ronda es una excepción explícita a esa convención. + +El cliente usa una fuente monotónica de intervalos: en web, `performance.now()`; en la futura app, un adaptador Dart validado que normaliza unidades/época y detecta suspensión. No se compara el reloj civil del teléfono como autoridad. El [perfil web/nativo](../platform/client-communication-profile.md) conserva el mismo intercambio y reglas, sin dar más crédito por ser Flutter. Con `c0` envío cliente, `s1` recepción servidor, `s2` envío servidor y `c3` recepción cliente, estima: + +```text +offset servidor-cliente ≈ ((s1 - c0) + (s2 - c3)) / 2 +RTT de transporte estimado ≈ (c3 - c0) - (s2 - s1) +``` + +Es un intercambio inspirado en las cuatro marcas de [RFC 5905](https://www.rfc-editor.org/rfc/rfc5905.html#section-8), no ejecutar NTP dentro del navegador. Las marcas se normalizan a unidades comunes de esa época. Se filtran muestras viejas, valores imposibles, colas atípicas y deriva; se conservan distribución, jitter, incertidumbre y frescura, no una precisión ficticia. El reloj visual converge gradualmente; suspender/reanudar una pestaña obliga a resincronizar antes de otra ronda. + +La estimación cliente sincroniza la presentación; **no autoriza puntuación**. El servidor mide asimismo desafíos/acuse con sus propias marcas y nonce, sin descontar un tiempo de procesamiento que declare el cliente. Conserva un historial acotado de calidad por conexión y ruta de pantalla/controlador. Un acuse no prueba que la persona vio el contenido. Usar mínimo/percentil bajo, varias muestras y congelar el presupuesto antes de revelar limita inflar un pico, pero un cliente puede demorar sistemáticamente acuses: no elimina el engaño. Por eso hay topes, incertidumbre y límites de publicación del perfil de reacción. + +La partida tiene una autoridad temporal por época, con lease/fencing en caso de varias réplicas. Todos sus ingresos, plazos y estimaciones se normalizan al mismo dominio; nunca se comparan relojes monotónicos de procesos distintos. Se guardan fechas duraderas y estado/generación de ronda para recuperación; [Go documenta que la parte monotónica no se serializa](https://pkg.go.dev/time#hdr-Monotonic_Clocks). Cambio de autoridad o salto de reloj que exceda el presupuesto invalida la ronda de reacción aún sin resolver y crea otra época antes de reanudar, sin adjudicar derrota. Las mediciones efímeras se rehacen; la configuración y resoluciones confirmadas permanecen. Otros perfiles recuperan plazos según su política declarada; si no pueden demostrar admisión correcta, tampoco inventan un resultado. + +## 5. Protocolo de ronda y distribución + +Estados propuestos: `preparing → armed → open → collecting → resolved`, con salida `void` por fallo de equidad. Reglas y motor definen la transición; workers duraderos ejecutan plazos con generación y autorización actuales. Requiere una ampliación explícita del contrato de lifecycle v2: no se inventa un callback en el SDK v1. + +El estado actual viaja en `game.snapshot.context.timing`; `round.prepare`, `round.armed`, `round.open`, `round.resolved` y `round.void` son eventos visibles dentro de `game.snapshot.events`, filtrados por proyección, **no sobres WSS independientes**. Un snapshot basta para recuperar una ronda sin reproducir eventos pasados. El cliente comunica preparación mediante `time.ready` y recibe `time.readiness`, ligados a `clockEpoch`, ronda/generación, suscripción y fuente vigentes. Son acuses técnicos acotados y no prueban visualización física ni permiten a la TV ejecutar una jugada. La autoridad confirma la transición de reglas después de validar esa preparación y las mediciones. + +1. **Preparar.** `round.prepare` identifica ronda/generación, contrato, recursos públicos y política temporal. Todas las fuentes/controladores necesarios precargan assets y acreditan disponibilidad técnica dentro del plazo; el servidor comprueba también muestras frescas. Una persona con mala conexión no pierde puntos por no llegar a esta barrera. Tras un número acotado de intentos se aplaza/cancela sin resultado, o se propone otra regla compatible y se confirma fuera de la ronda. +2. **Armar.** `round.armed` distribuye `clockEpoch`, `roundId`, generación, `startAtMs`, `answerDeadlineAtMs` y `collectUntilMs`. Se programa cuenta atrás con antelación. No contiene pregunta secreta, solución ni clave utilizable antes de tiempo. Preparar o armar no concede una oferta de respuesta. +3. **Abrir.** La transición confirmada publica `round.open` y las ofertas propias. Se registra cuándo el servidor libera el estímulo hacia cada fuente autorizada. Si la pregunta depende de imágenes, texto o audio nuevos, también su entrega pertenece al presupuesto temporal. Programar `startAtMs` no hace que ese contenido llegue simultáneamente: la liberación y renderizado siguen teniendo retardo. Precargar texto secreto y ocultarlo con CSS permitiría leerlo antes; cifrar y enviar la clave después traslada el problema a la entrega de la clave. +4. **Recoger.** El jugador envía una respuesta mediante `game.command` con `commandId`, `controlGeneration` y oferta/precondición que identifica ronda/generación. Actor y canal se derivan en servidor; la marca cliente, si existe para diagnóstico, no decide validez/posición. Primera respuesta válida por asiento/ronda, protegida con unicidad y recibo duradero. `game.ack` con recibo `applied` solo se emite tras confirmar respuesta sellada, recibo y outbox en una transacción; estar en una cola no equivale a estar aplicada. No anuncia todavía acierto, respuesta ajena o ganador. +5. **Cerrar y resolver.** Al vencer `collectUntilMs`, una transición confirma evaluación, empate/puntos y outbox juntos, después de la barrera de ingresos descrita abajo. La ventana de recogida permite esperar inputs en tránsito; no se proclama ganador por el primer acuse. No se publican respuestas/corrección antes del cierre si permitirían copiar. TV recibe el resultado público; cada jugador recibe su salida autorizada, todos con la misma revisión y ronda. + +`answerDeadlineAtMs` es el objetivo visual; `collectUntilMs` es el cierre efectivo de admisión en servidor. La gracia se fija y aplica a todos, está acotada y **no demuestra** que una respuesta recibida en ella se pulsó antes del objetivo. No se valida con `clickedAt`. El perfil de ventana puntúa acierto, sin premio por milisegundos; si el presupuesto no cubre la entrega/estabilidad, se aplica la política de incidente de la sección 7. Una ventana sin límite evita convertir la demora de red en velocidad puntuable, aunque sigue necesitando política operativa de desconexión. + +En el primer punto confiable de recepción se asigna `ingressAtMs`, antes de esperar bloqueo de sala o cola de reglas. Solo registros internos autorizados pueden suministrarlo. La recepción y su identidad se persisten de manera recuperable para no perder una respuesta llegada a tiempo al caer el nodo; el cierre drena ese ingreso duradero o usa una barrera que demuestra que no quedan candidatos anteriores. No basta comparar el momento del commit. Fallar esa garantía neutraliza la ronda; nunca se sustituye por la fecha afirmada por el cliente. Hay que medir también cola del proxy/transporte y del ingreso antes de la marca. + +La admisión usa una única generación de autoridad vigente; una réplica con lease vencido no puede agregar candidatos. La barrera de cierre sella esa generación y drena todos los ingresos admitidos hasta el límite antes de resolver. Pasar de `open` a `collecting` no invalida las ofertas de respuesta ni incrementa su época de precondición antes de procesar esos candidatos: una respuesta puntual no se rechaza por haberse retrasado SQL. Una caída entre recepción y persistencia que impida demostrar completitud se trata como incidente, nunca como prueba de que nadie respondió. La transferencia de mando se serializa con esa misma barrera. + +Las respuestas simultáneas usan precondición de ronda y revisión propia de actor, según el modelo de [concurrencia del motor](../game-engine-proposal.md#10-concurrencia-sin-conflictos-innecesarios). Que otro responda no invalida todas las ofertas pendientes ni fuerza a resincronizar por una revisión global ajena. Los recibos sobreviven a cierres y cambios de presentación; reintentar no cambia el instante de ingreso ya registrado ni permite elegir otra respuesta. + +Todos los mensajes de ronda se envuelven en la revisión/suscripción/proyección negociada del perfil multidispositivo. Las mediciones y créditos por jugador no se difunden al tablero. `game.snapshot` incluye el estado temporal autorizado y el tiempo actual: reconectar no reproduce cuenta atrás antigua, no reinicia el plazo y no vuelve a conceder respuesta. Ningún animador, idioma o notificación retrasa el habilitado de controles respecto al contrato. + +## 6. Reacción compensada: propuesta experimental + +Esta variante queda pendiente de fixtures y pruebas adversariales. No se habilita como forma inicial de quiz ni como ranking competitivo por haber documentado el algoritmo. La industria compensa con compromisos; no hemos encontrado en las fuentes una garantía general de orden humano exacto con navegadores no confiables. + +Se congela antes de abrir el estimador y el máximo de compensación de cada ruta. Una desconexión o aumento de RTT durante la pregunta no concede más crédito. Una ruta por encima del máximo permitido no se trata como una respuesta lenta perdedora: aplica la política de ronda neutral, aplazamiento o variante aceptada. El servidor reduce sesgo según la fuente fijada: + +| Fuente de la pregunta | Trayectos que afectan a comparar reacción | +| --- | --- | +| Una TV física común para todos | La revelación en esa TV es común; se estima el trayecto ascendente de cada móvil. Usar `RTT/2` implica simetría aproximada y exige incertidumbre. | +| Vista completa individual que revela al recibir contenido | Bajada del estímulo + renderizado + subida de la respuesta. Para el mismo dispositivo, descontar aproximadamente un RTT puede estimar reacción mejor que descontar solo medio RTT; no cubre jitter ni pantalla. | +| TV personal con móvil distinto | Bajada y renderizado de su TV + subida de su móvil. El RTT del móvil por sí solo no describe esa cadena. | +| Contenido público previamente conocido con apertura programada | Incertidumbre del reloj/apertura y subida del input. Solo sirve cuando conocer antes ese contenido no permite ventaja. | + +Para fuente individual, `reactionEstimateMs = ingressAtMs - stimulusReleaseAtMs - estimatedDisplayDelayMs - estimatedInputDelayMs`. Cada componente tiene tope y margen; no se resta ciegamente el RTT a todos los casos. Para TV común, su demora compartida no define el orden relativo: se compara ingreso menos subida estimada. El retardo de pantalla física no medido impide prometer precisión extrema. Mezclar TV remota y vista completa en reacción requiere presupuestos de presentación validados; si no los hay, el paquete rechaza esa combinación o emplea ventana sin clasificación por velocidad. + +La ruta de pantalla incluye el [dispositivo externo y su vía de acceso](multi-device-profile.md#televisores-y-dispositivos-externos): receptor Cast directo, app de TV, Silk, HDMI o duplicación. En duplicación, el acuse/reloj del navegador no mide cuándo aparece su imagen en la TV; tampoco lo mide el RTT del mando. No se aplica una compensación fija tomada de otra plataforma. Para fuentes individuales sin presupuesto de presentación validado se usa ventana sin premio por velocidad o se rechaza esa combinación de reacción. Una TV común conserva la revelación compartida, pero su demora puede afectar plazos y preparación. Cambiar de receptor/vía exige nueva generación y preparación entre rondas. + +Cada respuesta correcta obtiene un **intervalo estimado**, no un instante demostrado: `[estimate - uncertainty, estimate + uncertainty]`, acotado al dominio válido. Un ganador único solo se declara si su límite superior es menor que el límite inferior de todas las demás candidatas. Si no, comparten empate quienes aún podrían ser primeras: las candidatas cuyo límite inferior no supera el menor límite superior del conjunto. No se usa orden de socket, ID de persona o commit para romper ese empate. La política de puntos compartidos/desempate por nueva pregunta pertenece al juego. + +Ejemplo bajo hipótesis simplificadas: reacción real de A a 400 ms, B a 350 ms, y subidas de 20/100 ms hacen llegar sus inputs a 420/450 ms. El orden de paquetes daría ventaja a A. Compensar recuperaría estimaciones 400/350 ms si las subidas fueran conocidas; con incertidumbre ±40 ms los intervalos se solapan, de modo que este perfil declara empate. Es ilustración matemática, no medición de nuestro sistema. + +Se espera hasta el cierre de recogida antes de confirmar ganador; cualquier optimización de cierre temprano necesita demostrar que ningún candidato admitido pendiente puede cambiar el conjunto. Retardo deliberado, pestaña suspendida, hardware y proveedores externos siguen siendo limitaciones. Commit/reveal puede impedir cambiar una respuesta tras ver otras, pero no prueba el instante físico de reacción; no se presenta como solución de latencia. + +Los agentes IA reciben el estímulo solo al abrir y usan decisiones duraderas. La latencia de un modelo no equivale a tiempo humano ni a RTT de navegador. Un juego de reacción humano/IA necesita reglas declaradas de demora/dificultad y ensayos propios; sin ese perfil aprobado no admite agentes en carreras de velocidad. Juegos por turnos y acierto en ventana conservan las validaciones normales del [perfil virtual](virtual-player-profile.md). + +## 7. Cambio de presentación y comprobación + +El recorrido invitación → TV personal + mando siempre ofrece «Jugar solo en el móvil». Conserva membresía, asiento, datos autorizados, comando pendiente y recibo; cierra el vínculo personal al confirmar `Mobil` y obtiene snapshot `player-full`. No repite la invitación ni afecta a las TVs de otros jugadores. + +En turnos el cambio usa la barrera de suscripción habitual. En rondas temporizadas se puede solicitar siempre y se ejecuta en el punto seguro definido por el perfil; para reacción, entre rondas. Fuente y crédito temporal no se sustituyen a mitad de una pregunta ni se reinicia su reloj. Si se pierde la TV durante una ronda y no hay fuente válida, se neutraliza según política antes del cambio. En reacción la neutralización afecta a la comparación completa, no se inventa una derrota para el jugador. Los reinicios están acotados; al superarlos se detiene sin ganador o se acuerda otro perfil, sin suspensiones de reputación automáticas por una mala red. + +Una declaración cliente de «lag» no anula por sí sola una ronda. El perfil debe definir criterios observables por servidor, motivo de incidente, presupuesto acumulado de reintentos y salida al agotarlo, con pruebas de desconexión deliberada para evitar repetir hasta acertar. Los contadores no se reinician al reconectar, cambiar TV o transferir mando. Una ronda anulada que haya revelado contenido se sustituye por otra pregunta/estímulo y generación; nunca se repite el mismo secreto como si nadie lo conociera. Se conservan puntuaciones de rondas ya resueltas; detener la comparación pendiente sin ganador no revierte resultados confirmados. Cuando no se pueda continuar con equidad, se pausa/cancela según la política publicada, sin inventar victoria o derrota por calidad de red. Esta política de incidentes es requisito de publicación, aún sin esquema ejecutable. + +Para una ronda temporizada sin resolver, esta política prevalece sobre `sharedDisplay.onLoss: continue`: no se continúa una carrera sin su fuente fijada. Fuera de esa ronda rige el fallback multidispositivo. El cambio de mando web/Flutter usa además la generación y barrera del [perfil de comunicación](../platform/client-communication-profile.md#continuidad-del-mando-entre-conexiones); no transfiere presupuestos temporales a otra ruta a mitad de la pregunta. + +Antes de habilitar este perfil se necesitan esquemas/fixtures de negociación, reloj/época, ronda y comandos; persistencia de ingresos/plazos; estimador versionado y pruebas con respuestas de tiempos conocidos. La matriz de red incluye RTT, jitter, asimetría, pérdidas/retransmisiones y bloqueo de cabeza de cola, reconexión, cambio de nodo, carga SQL/proxy y deriva. Se ensayan TV común, varias TVs personales, Desktop/Mobil y cambio a móvil completo, con diferentes frame rates, pestañas en segundo plano y dispositivos reales. + +Se prueban timestamps/pings falsos, acuses retardados, doble respuesta, replay de otra ronda/época, segunda pestaña o app que intenta leer antes desde otra fuente, cambio de perfil/ruta tras revelar, secretos precargados, cierre concurrente, transición a recogida con respuestas en cola y respuesta perdida después de commit. El resultado no debe variar solo por retrasar el procesamiento SQL después de `ingressAtMs`. Se ensayan neutralizaciones repetidas sin reiniciar cuotas y sin reutilizar preguntas reveladas. Dentro del presupuesto validado, la compensación y los empates deben cumplir los vectores publicados; fuera de él, el fallo debe acabar en la salida neutral prevista. No basta comprobar que dos pantallas muestran una cuenta atrás parecida. + +Las métricas separan tiempo de red, cola, reglas/commit, fanout y renderizado estimado; se comparan puntuaciones por rangos de conexión y fuente sin guardar contenido privado innecesario. La [capacidad](../platform/capacity-and-scaling.md) se prueba con ráfagas de respuestas simultáneas y probes acotados. Mantener la misma revisión autoritativa no acredita por sí solo equidad de reacción. diff --git a/docs/game-engine/virtual-player-profile.md b/docs/game-engine/virtual-player-profile.md new file mode 100644 index 0000000..fa3ce86 --- /dev/null +++ b/docs/game-engine/virtual-player-profile.md @@ -0,0 +1,101 @@ +# Jugadores virtuales e instrucciones del agente — propuesta de contrato v2 + +**Estado:** decisión de producto del 6 de octubre de 2026 y propuesta de diseño. Cada juego debe declarar en sus propiedades si admite jugadores virtuales e incluir las instrucciones de sus agentes. Los nombres de campos siguientes son propuestos; todavía no hay esquemas ejecutables, controlador IA, API de plazas virtuales ni runtime compatible. El [formato v1](format-and-protocol.md) no cambia ni admite estos campos por existir este documento. + +## 1. Propiedad del juego + +Una plaza de jugador puede tener un controlador humano o virtual. El motor mantiene asientos, turnos, acciones, información privada y resultados; la plataforma vincula cada asiento a una persona autorizada o a una instancia de agente limitada a esa partida. Una instancia virtual no es una cuenta humana, una sesión de navegador ni una pantalla compartida. + +El paquete de juego es la fuente de su soporte IA: modalidades compatibles, número de plazas virtuales, perfiles, dificultades, instrucciones y comportamiento ante fallos. La plataforma ejecuta únicamente perfiles publicados por el equipo y puede deshabilitarlos por disponibilidad, seguridad o presupuesto. No habilita IA en un juego que no la declare. + +La sala permite elegir los perfiles publicados antes del inicio y muestra qué plazas son virtuales. Los humanos confirman esa configuración al marcarse listos. Cambiarla invalida los listos; al comenzar se fijan controlador, perfil, dificultad y versión/digest del paquete. No se sustituye automáticamente a una persona desconectada por IA ni se cambia su controlador en una partida activa. + +## 2. Extensión del manifiesto + +Se propone `virtualPlayers` como propiedad opcional del futuro `game.json` v2, coordinada con el [perfil multidispositivo](multi-device-profile.md) y el perfil ejecutable Go. Su ausencia significa que el juego no admite jugadores virtuales. No cambia por sí sola las versiones del protocolo ni de presentación: sus ampliaciones se definirán expresamente al crear los contratos ejecutables. + +Fragmento ilustrativo para Conecta 4, no manifiesto instalable: + +```json +{ + "virtualPlayers": { + "profileVersion": 1, + "minHumans": 1, + "maxVirtualPlayers": 1, + "profiles": [ + { + "id": "connect4-player", + "version": "1.0.0", + "labelKey": "agents.connect4.name", + "modes": ["personal", "mesa"], + "controllerId": "connect4-strategy-v1", + "instructions": { + "path": "agents/connect4/instructions.md", + "locale": "es" + }, + "difficulties": ["easy", "normal", "hard"], + "defaultDifficulty": "normal", + "limits": { + "decisionTimeoutMs": 2000, + "maxAttempts": 2 + }, + "onFailure": "block-without-result" + } + ] + } +} +``` + +`players.min/max` sigue contando todas las plazas, humanas y virtuales. `minHumans`, `maxVirtualPlayers`, modos y combinaciones de perfiles deben ser compatibles con esos límites y con las reglas. Esta primera propuesta requiere al menos una persona; las simulaciones sin humanos serían un perfil de pruebas separado. Cada modo referenciado debe existir. Las claves de nombre, dificultad y explicación visible se traducen según la [política de idiomas](../platform/localization.md). + +`controllerId` referencia un adaptador registrado y versionado por el servidor: puede usar estrategia local en Go o un agente basado en modelo de lenguaje. No es una ruta ejecutable, una URL arbitraria ni permiso para instalar código. Cada dificultad necesita una configuración comprobable para ese controlador; escribir «difícil» en las instrucciones no demuestra su nivel. Los valores de tiempo e intentos del ejemplo son propuestas, no presupuestos medidos. + +Las instrucciones son obligatorias en cada perfil y pertenecen al propio paquete. Su ruta debe permanecer dentro de él, existir y figurar con sus bytes y hash en el inventario/lock. Cambiarlas exige una nueva versión/digest de paquete y perfil; las partidas existentes conservan los anteriores. No se descargan instrucciones mutables durante la partida. Su inclusión no obliga a servirlas al navegador: el catálogo expone solo la descripción pública del agente. + +## 3. Contenido de las instrucciones + +El juego proporciona el objetivo del jugador, las reglas que necesita conocer, el significado de su vista, las acciones y parámetros disponibles, el papel cooperativo o competitivo y las pautas de estrategia/dificultad. También explica la información que permanece oculta y el formato de respuesta. Estas instrucciones complementan las reglas ejecutables; no crean acciones, permisos ni resultados que el motor no permita. + +Ejemplo del contenido de `agents/connect4/instructions.md`: + +> Ocupas una plaza de Conecta 4. Tu objetivo es conseguir cuatro fichas propias alineadas en horizontal, vertical o diagonal y evitar que lo consiga el rival. Recibes el tablero visible, tu ficha, la dificultad y las ofertas vigentes. Cuando puedas actuar, selecciona una oferta `drop` y una columna incluida en sus restricciones. Para la dificultad normal, prioriza una victoria inmediata, después bloquear una victoria inmediata rival y después construir amenazas propias. Devuelve únicamente una acción estructurada compatible con el esquema proporcionado. No inventes columnas disponibles ni modifiques el tablero: el motor coloca la ficha y confirma el resultado. + +En un controlador de estrategia Go, la implementación de estos criterios se revisa junto con el documento y sus fixtures. En un controlador con modelo de lenguaje, el adaptador incorpora las instrucciones versionadas al contexto del agente. La interfaz de decisión es común; el motor nunca interpreta lenguaje natural para aplicar una jugada. + +La entrada contiene solo la proyección autorizada de ese asiento (`view`, `context`, eventos visibles necesarios y `availableActions`), con la revisión/precondición de las ofertas. Las reglas y definiciones públicas necesarias pueden acompañarla; no se entrega el estado global, manos ajenas, futuras tiradas, chat, credenciales ni datos de cuenta. La salida es una propuesta `action: { type, payload }`, validada contra los esquemas y las reglas del juego. El servidor genera la identidad del comando y deriva el asiento del vínculo interno; la respuesta del agente no puede elegir otro actor. + +La misma restricción se aplica a instrucciones, recursos y memoria: no incluyen bancos de soluciones, semillas de azar, preguntas futuras ni secretos de otras plazas para eludir la proyección. Que un archivo forme parte del paquete no autoriza enviarlo al controlador; el publicador revisa el contexto completo y cada recurso permitido. + +Los modos del ejemplo deben existir en `interaction.modes`; admitir IA en `mesa` no le asigna un móvil, una TV ni un QR. La instancia recibe una proyección interna propia autorizada, independiente de `TVBoard`/`Desktop`/`Mobil`. El [perfil temporal](synchronization-profile.md) restringe juegos de reacción: el agente no conoce la pregunta antes de apertura, y competir por velocidad con humanos exige reglas publicadas de demora/dificultad. Sin ese perfil aprobado no se habilita IA en carreras; el coste/latencia de un modelo no se compensa como ping humano. + +Textos de jugadores y contenido narrativo se tratan como datos, no como instrucciones que puedan ampliar permisos. La plataforma fija las herramientas y restricciones del adaptador; un archivo del juego no concede acceso libre a red, archivos o base de datos. Un eventual proveedor externo recibe exclusivamente la proyección mínima autorizada y exige una evaluación propia antes de habilitarse. + +## 4. Ejecución, fallos y recuperación + +El controlador pertenece a tareas del servidor, separado de las funciones puras de reglas. La deliberación, incluida cualquier llamada a modelo, se realiza fuera de los bloqueos y transacciones de sala/partida. El juego declara límites de decisión y de intentos; la plataforma aplica además sus cuotas de concurrencia, cómputo y coste. Un intento no reinicia indefinidamente el presupuesto de la decisión. + +El trabajo pendiente y su generación se persisten con la transición que habilita actuar al agente. El worker obtiene su proyección autorizada y una precondición; calcula una propuesta; guarda de forma duradera el sobre elegido y su `commandId`; y lo entrega al mismo caso de uso que valida comandos humanos. Una recuperación consulta/reenvía ese mismo sobre. Workers concurrentes deben reclamar el trabajo y elegir una única propuesta persistida; no pueden confirmar dos movimientos por el mismo trabajo. + +Cada oportunidad de actuar tiene identidad duradera, vencimiento y presupuesto total de tiempo/intentos. `decisionTimeoutMs` limita el tiempo total de deliberación de esa oportunidad, no se renueva por cada llamada; `maxAttempts` limita todos sus intentos, incluidos fallos y propuestas obsoletas. Reprogramar, reiniciar proceso o cambiar de worker conserva ambos contadores. Una nueva oportunidad solo nace de una transición de reglas que la habilite, no de un bucle de reintento. Reclamar el trabajo usa lease con generación: una respuesta de un worker sustituido no puede elegir o sobrescribir la decisión confirmada. + +El futuro contrato interno identifica los recibos por partida + instancia virtual autorizada + `commandId`, con huella del sobre completo. Reutilizar el identificador con otra acción o precondición se rechaza. Este vínculo se deriva del trabajo duradero y no de un `actor` enviado por el agente; no altera la identidad basada en principal humano del protocolo v1. El worker llama al servicio de aplicación dentro del backend, sin crear una cuenta ficticia ni hacer HTTP a su propia API. + +Antes de aplicar se revalidan el vínculo de la instancia, la política actual, versiones, fase, revisión y legalidad. Una propuesta obsoleta no se aplica: se comprueba si el agente sigue habilitado y se programa una nueva decisión dentro de presupuestos acotados. Una respuesta ilegal, timeout o fallo del proveedor no concede una victoria ni permite al agente saltarse validaciones. + +`onFailure: block-without-result` expresa indisponibilidad operativa recuperable, separada del resultado. Al agotar los límites se registra el incidente y se detienen decisiones nuevas hasta recuperación autorizada; no se mantiene un bloqueo SQL. Cualquier estrategia alternativa o salida por plazo necesita declaración versionada, validación y pruebas propias antes de ofrecerse. No se oculta un cambio de dificultad/controlador durante la partida. + +El bloqueo debe coordinarse con los plazos de juego: no puede prometer ausencia de resultado mientras un timeout paralelo adjudica derrota por ese mismo fallo. El paquete declara qué reloj pausa, un máximo de recuperación y la salida al agotarlo; en una ronda temporizada se aplica también la política de incidentes del perfil temporal. No reabre respuestas ni borra puntuaciones ya confirmadas. El ejemplo anterior omite deliberadamente esa política operativa completa y **no es publicable** hasta concretarla. La recuperación autorizada tampoco renueva intentos ilimitadamente ni cambia dificultad a escondidas. + +El historial conserva las acciones aceptadas, recibos y configuración del controlador, instrucciones/digest, dificultad y versión efectiva del adaptador/modelo utilizado. Si hay memoria del agente, debe ser duradera, limitada y aislada por instancia/partida; no comparte secretos entre plazas. La reproducción del juego utiliza las acciones confirmadas y la traza de azar del motor, no vuelve a consultar al agente ni presupone que un modelo produzca siempre la misma respuesta. No se requiere almacenar razonamiento interno ni payloads privados completos en logs. + +## 5. Admisión y protección + +Una IA se identifica como virtual y no puede asumir anfitrión, tutela, contactos, credenciales humanas ni permisos de chat. En salas protegidas no sustituye al adulto responsable que exige la política vigente. Jugar una persona contra agentes requiere definir expresamente la modalidad de práctica y su admisión antes de habilitarla para invitados o menores; rellenar plazas no elude los permisos actuales. + +Este perfil autoriza acciones de juego estructuradas. Conversación, voz o narración generada visible serían capacidades adicionales, con contratos de contenido y protección propios; no se habilitan por adjuntar instrucciones al agente. También los nombres/avatares de agentes y sus descripciones pasan por publicación y proyecciones seguras. + +## 6. Aceptación e implementación + +Antes de publicar soporte IA se necesitan esquemas v2 y perfil Go ejecutables, contrato de controlador, representación de plazas virtuales, tareas/decisiones duraderas y operaciones autorizadas de configuración en sala. El bootstrap solo podrá anunciarlo cuando ese corte esté implementado y verificado. + +La aceptación comprueba propiedades y archivos obligatorios; versiones/hashes; compatibilidad de modos, plazas y dificultades; acciones válidas y ausencia de información ajena; límites y fallos; respuesta obsoleta; duplicados y workers concurrentes; reinicio antes/después de guardar decisión o confirmar acción; revocación; memoria aislada; idiomas y protección. Conecta 4 será el primer ensayo; Hundido y Brisca comprobarán después los límites de información oculta. diff --git a/docs/platform-spec-v2.md b/docs/platform-spec-v2.md index 20c58e1..f88f863 100644 --- a/docs/platform-spec-v2.md +++ b/docs/platform-spec-v2.md @@ -1,6 +1,6 @@ # 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. +**Estado:** propuesta revisada el 6 de octubre de 2026. `games2` implementa únicamente la base de salud, bootstrap y catálogo descrita en el [handoff](HANDOFF-2026-10-06.md); los flujos de este modelo siguen pendientes. Los registros de hallazgos del [5](platform/review-2026-10-05.md) y [6 de octubre](platform/review-2026-10-06.md) conservan defectos, correcciones documentales y comprobaciones pendientes; no certifican un servidor desplegado. ## 1. Alcance y autoridad de los documentos @@ -10,14 +10,17 @@ La plataforma organiza identidad, acceso, catálogo, salas, comunicación y oper | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | 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. | +| [Modelos de datos compartidos](platform/shared-data-models.md) | Fuente JSON Schema y DTOs TypeScript/Go/Dart; primitivas y núcleo de error comunes, separados de entidades privadas. | | [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. | +| [Arquitectura de tres capas](platform/architecture.md) | API y servidor/motor Go, frontend y adaptació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. | +| [Multidispositivo](game-engine/multi-device-profile.md), [sincronización](game-engine/synchronization-profile.md) y [web/Flutter](platform/client-communication-profile.md) | Extensiones v2 propuestas: proyecciones, mandos, rondas, transporte y renderers; no amplían los esquemas v1 implícitamente. | +| [Jugadores virtuales](game-engine/virtual-player-profile.md) | Propiedades/instrucciones por juego, autorización y límites de los controladores IA. | 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. +Las capas fijadas son frontend Svelte, API Go y servidor/motor propio Go. Se propone un monolito modular con PostgreSQL; API y dominio son capas de código que inicialmente comparten proceso y transacción. La producción Node corresponde a `games` v1. El SDK de diseño conservado aquí es TypeScript; la implementación 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 @@ -34,7 +37,7 @@ flowchart LR 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. +El lanzamiento inicial excluye espectadores remotos con cuenta propia, 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. El [perfil multidispositivo](game-engine/multi-device-profile.md) propone para una fase posterior una pantalla compartida pasiva, vinculada a una sala y sin plaza ni permiso de chat; no equivale a habilitar espectadores. 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 @@ -62,8 +65,12 @@ Estas diferencias proceden de los módulos históricos `server/service.mjs`, `se | 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. | +| Instancia virtual (propuesta) | Identificador interno, sala/partida, asiento, perfil/digest y dificultad del agente | Solo existe si el paquete declara el perfil; no es cuenta humana ni obtiene permisos sociales. Su persistencia y API están pendientes. | | 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. | +| Acceso QR (propuesta) | Finalidad, sala/paquete o preparación, generación, hash y vencimiento | En mesa `screen-qr` sustituye la invitación; un QR de mando personal solo continúa la admisión convencional y vincula dispositivos. | +| Vínculo de pantalla (propuesta) | Dispositivo, sala, alcance común/personal, propietario, generación y revocación | Sin cuenta ni plaza; proyección pública. La TV personal depende además de la membresía propietaria. | +| Ronda temporal (propuesta) | Perfil fijado, época de reloj, generación, plazos, fuentes e ingresos duraderos | El orden de commit no determina reacción; recogida/resolución y salida neutral siguen reglas publicadas. | +| Partida | `matchId`, asientos `p0…`, versiones y estado del motor | El vínculo asiento-cuenta pertenece a plataforma. El perfil virtual propuesto añade un vínculo a instancia de agente; ambos quedan fijados al 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. | @@ -79,19 +86,25 @@ El servidor deriva actor, asiento, rol, fecha y resultado. Un cliente puede iden | Estado de sala | Significado | Operaciones | | -------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------- | -| `waiting` | Sin partida todavía | Entrar/salir, configurar, listo, colores, invitar, cancelar; chat solo si el principal es elegible. | +| `waiting` | Sin partida todavía | Entrar/salir, configurar, listo, colores, cancelar; invitar solo en entrada convencional; 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. +El [perfil multidispositivo propuesto](game-engine/multi-device-profile.md) fija también `modeId` y `entryMethod: standard|screen-qr`. Cada jugador selecciona una presentación compatible `TVBoard`, `Desktop` o `Mobil`; esa elección no modifica admisión ni reglas. La mesa presencial `TVBoard` se crea con pantalla común y entrada QR, sin invitaciones. En una sala convencional, una invitación introducida en Smart TV puede enlazar TV personal y móvil mando; ese recorrido exige poder pasar a móvil completo conservando la plaza. El [perfil temporal propuesto](game-engine/synchronization-profile.md) fija reloj, rondas, fuente de estímulo y punto seguro de cambio, con empates/neutralización cuando no se puede establecer una comparación válida. + 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. +El [perfil de jugadores virtuales](game-engine/virtual-player-profile.md), todavía pendiente de contratos ejecutables, amplía las propiedades del juego con perfiles IA e instrucciones incluidas. Humanos y agentes cuentan como plazas; las membresías humanas conservan sus permisos y las instancias virtuales solo pueden actuar en su asiento. Configurar IA exige autorización en espera y confirmación de los humanos mediante «listo»; la plataforma comprueba disponibilidad de cada instancia al iniciar. Ninguna IA acredita edad, supervisa menores ni sustituye al anfitrión responsable. No se reemplaza a un participante desconectado o retirado durante la partida. La admisión de práctica contra IA se definirá expresamente antes de habilitarla. + 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. +En una mesa `screen-qr`, la transferencia invalida su acceso QR y exige reautorizar la pantalla común y emitir otra generación antes de nuevas altas. No crea invitaciones como sustitución. Las TVs personales mantienen su vínculo solo mientras siga vigente la autorización propia y la de protección. + 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 @@ -130,7 +143,7 @@ Estados de cuenta: `pending-email`, `active`, `restricted`, `deletion-pending`, 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). +Sesiones web: token opaco, hash servidor, cookie `HttpOnly; Secure; SameSite=Lax; Path=/` sin dominio compartido; expiración absoluta e inactiva impuestas en servidor. El [perfil nativo futuro](platform/client-communication-profile.md#3-autenticación-web-y-nativa) usa otro adaptador de credencial emitida por la misma plataforma, con iguales límites y permisos. [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. @@ -138,6 +151,10 @@ La política de [protección](platform/child-safety.md) revalida acceso en petic ## 6. Invitaciones y admisión +Las reglas de invitación siguientes corresponden a `entryMethod: standard`. La mesa presencial `screen-qr` usa un acceso de finalidad propia visible en TV y rechaza emisión/consumo de invitaciones. Preparar la mesa en TV no crea una plaza: el anfitrión autorizado confirma desde su móvil la creación, membresía, vínculo común y QR de entrada atómicos. Todos los escaneos revalidan admisión, capacidad, contactos y tutela; la presencia física o el QR no concede esos permisos. + +En acceso convencional desde Smart TV, introducir la invitación prepara un QR personal de mando sin consumirla. La confirmación autenticada desde móvil admite o recupera una única membresía y vincula su TV; los reintentos no duplican plaza/usos. Ese QR no sirve para admitir a otras personas. «Jugar solo en el móvil» cambia a `Mobil` mediante barrera de sincronización y revoca la TV personal, sin otra invitación. Véanse las [rutas y finalidades propuestas](platform/api-and-events.md#preparación-tvboard-qr-de-entrada-y-enlace-de-mando-propuesta-v2). + 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. @@ -182,17 +199,19 @@ PostgreSQL confirma cambios y outbox juntos. El publicador entrega al menos una 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. +HTTP mutante del perfil web exige origen permitido y CSRF vinculado a sesión/contexto; su binding Socket.IO verifica ambos en handshake. El WSS propuesto para web/nativo usa un ticket ligado a desafío/conexión y adaptador de sesión, con validación de origen web y autorización común; omitir `Origin` no valida una cookie ni acredita una app. 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. +El [perfil de capacidad](platform/capacity-and-scaling.md) propone ensayar 100.000 personas jugando simultáneamente **repartidas entre salas**. Define conexiones adicionales, trabajo por acción, latidos, fanout y fallos que deben medirse. No es un resultado actual ni implica que una sala admita 100.000 participantes. + +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. La [política de idiomas](platform/localization.md) desarrolla el alcance multilingüe; la identidad visual y el sistema de tokens quedan pendientes de definición. ## 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. +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 y servidor/motor propio Go. También quedan fijados jugadores virtuales declarados por juego con sus instrucciones, `TVBoard`/`Desktop`/`Mobil`, mesa presencial por QR sin invitaciones, TV personal tras invitación con paso a móvil completo y futura app Flutter **solo de controles**. Tableros y vistas completas siguen en la web; desde Flutter el paso a móvil completo abre `Mobil` web conservando membresía/recibos y transfiriendo el mando. Requisito técnico derivado: edad desconocida no cuenta como adulta; SMS acredita posesión de número, no edad o identidad civil. Las estructuras v2, el mando con generación y los algoritmos temporales son propuestas técnicas, pendientes de esquemas y ensayos; no equivalen a implementación ni garantizan orden humano exacto bajo cualquier lag. | ID | Base desarrollada en políticas | Dependencia real antes de activar | | --- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | diff --git a/docs/platform/api-and-events.md b/docs/platform/api-and-events.md index ae8b1bf..6019a2a 100644 --- a/docs/platform/api-and-events.md +++ b/docs/platform/api-and-events.md @@ -1,12 +1,16 @@ # 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). +**Estado:** borrador revisado el 6 de octubre de 2026; salvo las rutas iniciales de catálogo/bootstrap descritas en el [handoff](../HANDOFF-2026-10-06.md), 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 registros de revisión del [5](review-2026-10-05.md) y [6 de octubre](review-2026-10-06.md). El protocolo `game.*` conserva su [contrato v1](../game-engine/format-and-protocol.md); el [perfil multidispositivo](../game-engine/multi-device-profile.md) propone su versión 2. ## 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. +La API y el servidor/motor de `games2` usan Go por decisión del usuario. Los contratos de dominio no dependen del lenguaje. [Arquitectura](architecture.md) propone un backend modular; implementar reglas y cambiar el binding documentado Socket.IO por WebSocket son trabajos separados, aún pendientes. 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. +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. La excepción explícita son marcas temporales de ronda con sufijo `Ms`: intervalos monotónicos en milisegundos ligados a `clockEpoch`, según el [perfil temporal](../game-engine/synchronization-profile.md#4-reloj-medición-y-autoridad), no fechas UTC. + +Los [modelos compartidos](shared-data-models.md) fijan primitivas, mensajes y `ErrorData` para Go, Svelte y Flutter desde una única fuente JSON Schema con DTOs generados. Hay verificación estructural de ese contrato base; OpenAPI y los esquemas completos de flujos/sobres v2 siguen pendientes. La adopción del nuevo error en runtime es un corte explícito, no una modificación silenciosa de los mensajes `game.*` v1. + +Las lecturas con texto humano negocian idioma por `Accept-Language` y declaran `Content-Language` y `Vary: Accept-Language`; claves, códigos, permisos y revisiones no cambian por lengua. La selección explícita y el fallback se definen en [localización](localization.md). En el catálogo inicial, cada ficha declara su `locale` real para no ocultar una traducción ausente. 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. @@ -21,7 +25,9 @@ Las solicitudes tienen esquemas cerrados por versión. Se rechazan campos descon 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. +El perfil web HTTP usa cookie opaca; mutaciones y resolución de secretos exigen origen permitido y token CSRF ligado al contexto de navegador/sesión. Sus 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. + +El [perfil web/Flutter propuesto](client-communication-profile.md) añade un adaptador de sesión nativa emitida/revocable por la misma plataforma, con credencial HTTPS y autenticación WSS mediante ticket breve de conexión. No aplica a una app una comprobación de cookie/CSRF de navegador como sustituto de identidad, ni acepta ausencia de `Origin`/etiqueta Flutter como autorización. Son extensiones futuras con esquemas y pruebas propios; el binding Socket.IO v1 y las rutas ejecutables actuales no adquieren soporte nativo por existir esta propuesta. Permisos, DTO y recibos de dominio son comunes a ambos clientes. É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. @@ -47,7 +53,7 @@ La ausencia de recibo no reserva la clave: solicitudes simultáneas se serializa } ``` -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. +Recibo rechazado al adoptar el perfil común v2: `{ status: "rejected", error: ErrorData, resource, observedRevisions }`, con `error.origin: server` y `error.outcome: rejected`. El error es duradero y no se traduce ni cambia al repetir; la correlación del intento pertenece al sobre. Los contratos v1 que usaban `code` se normalizan mediante adaptador explícito. 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. @@ -69,7 +75,7 @@ Las tablas indican operaciones mínimas y campos relevantes. Las políticas de c | 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. | +| `GET /api/v2/bootstrap` | `serverTime`, versiones, `defaultLocale`, `supportedLocales`, 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. | @@ -94,9 +100,9 @@ Callbacks de edad/SMS son interfaces servidor a servidor autenticadas, con audie | `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`. | +| `POST /api/v2/rooms` | `game: { id, version, packageDigest, dependencyLockDigest }`, capacidad, visibilidad, nombre, configuración; `modeId` y `presentationProfile` inicial al adoptar perfil v2; `tvBoardSetupToken?` para preparación desde TV | Juego; fija la selección exacta o rechaza `PACKAGE_NOT_CREATABLE`. En preparación TV confirma sala, anfitrión, vínculo y QR de entrada en una unidad de trabajo. | | `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/rooms/{roomId}/memberships` | `invitationToken?` o `screenEntryToken?`, excluyentes según `entryMethod` → membresía | Juego; validación atómica de capacidad, finalidad/generación del token, estado y protección. Un miembro actual no ocupa otra plaza. | | `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. | @@ -109,6 +115,65 @@ La URL compartida propone `/join#invite=TOKEN`: el fragmento no se envía en la 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. +Al adoptar el perfil v2, `entryMethod: standard|screen-qr` se deriva del modo publicado y queda fijado en la sala. Crear la mesa presencial `TVBoard` usa `screen-qr`; sus rutas de emisión, rotación, resolución o consumo de invitaciones se rechazan. `Desktop`/`Mobil` usan el flujo convencional, que también puede admitir `TVBoard` como presentación personal del jugador invitado si el paquete lo declara. Elegir una presentación no cambia el método de entrada existente. + +### Pantallas compartidas (extensión futura del perfil multidispositivo) + +Estas rutas se reservan para salas cuyo `modeId` permita `interaction.sharedDisplay` en el [perfil v2](../game-engine/multi-device-profile.md). Distinguen pantalla común `displayScope: room` y TV personal `displayScope: participant`; el servidor deriva propietario/alcance del flujo autorizado y del paquete, nunca de un campo que conceda permisos por sí solo. No forman parte de la base ejecutable actual ni conceden acceso a un espectador remoto. La pantalla obtiene un contexto de dispositivo anónimo y acotado por `bootstrap`, separado de cuentas y membresías; todas las respuestas llevan `Cache-Control: private, no-store`. + +| Método y ruta | Solicitud / datos | Acceso y efecto | +| --- | --- | --- | +| `POST /api/v2/displays/challenges` | `issuedAt` → `challengeId`, `challengeToken` opaco de al menos 128 bits, `expiresAt`, QR con ese token en fragmento y código manual opcional | Contexto de pantalla emitido por servidor, origen y CSRF válidos; sin `roomId` ni datos de partida. `Idempotency-Key` liga un único desafío a ese contexto. | +| `GET /api/v2/displays/current` | Estado propio `waiting\|paired\|revoked\|expired`, caducidad y, solo si está vinculado, `roomId`, `displayId` y alcance; en pantalla común de mesa en espera, juego público apto y QR de entrada vigente | Únicamente el contexto autorizado; no devuelve lista de sala, membresía propietaria de otra persona ni secretos de juego. El QR de altas nunca se entrega a una TV personal. | +| `GET /api/v2/rooms/{roomId}/displays` | `displayRevision`, pantallas autorizadas, estado y caducidad, sin código de emparejamiento | Gestión de pantalla común por permiso; miembro con TV personal solo obtiene su propio vínculo. | +| `POST /api/v2/rooms/{roomId}/displays` | `issuedAt`, exactamente uno de `challengeToken` o `challengeCode`, alcance solicitado compatible, `expectedDisplayRevision` → recibo, `displayId` | Consume el desafío y ambos alias; deriva el alcance: común exige gestión de sala; personal exige membresía propia. En protegidas interviene el responsable autorizado. No recibe un asiento elegido por cliente como autoridad. | +| `POST /api/v2/rooms/{roomId}/displays/{displayId}/revoke` | `issuedAt`, `expectedDisplayRevision` → recibo | Gestor autorizado de pantalla común, propietario de TV personal o responsable autorizado; invalida inmediatamente sesión y suscripción correspondientes. | + +El límite propuesto es 5 minutos y 5 intentos fallidos por desafío reconocido, además de cuotas por contexto, principal, IP y sala. Los códigos inexistentes también consumen cuota de búsqueda para impedir enumeración. El alias manual opcional usa 10 símbolos base32 aleatorios (50 bits); **no** satisface el requisito de 128 bits del token QR ni sirve de sesión. Ambos identifican el mismo desafío de un solo uso y se invalidan juntos. Colisiones de alias se rechazan/regeneran al crear; todos los límites se fijan en los futuros esquemas antes de habilitar el flujo. + +Los secretos se generan criptográficamente; nunca se registran ni transmiten en URL de petición, analítica o referer. El QR usa el token en fragmento; la aplicación lo pasa a memoria, limpia el historial y lo entrega solo en el cuerpo del POST, sin cargar terceros. Repetir exactamente la creación con la misma clave recupera la respuesta cifrada únicamente durante su vigencia y desde el contexto original; fuera de ella devuelve recibo sin secreto. Emitir otro desafío revoca el anterior de ese contexto; no renueva intentos acumulados. No se reutilizan códigos de cuenta infantil ni invitaciones. + +Código inválido, caducado, consumido o ajeno produce el mismo `DISPLAY_CHALLENGE_UNAVAILABLE`; no se revela qué salas o pantallas existen. Revisión de pantallas obsoleta produce `STALE_DISPLAY_REVISION`; modo incompatible, `DISPLAY_MODE_UNAVAILABLE`; cuota de una pantalla ocupada, `DISPLAY_LIMIT_REACHED`. Ninguno de estos rechazos devuelve el código ni la identidad de la pantalla ajena. + +La vinculación toma el bloqueo de sala, verifica permiso actual, política de protección, `modeId` compatible, desafío vigente y `expectedDisplayRevision`. Incrementa `displayRevision`; inicio de partida y vinculación compiten bajo el mismo bloqueo para que `required-at-start` no observe una pantalla fantasma. La propuesta admite una pantalla común por sala y una personal por membresía humana, con cuotas técnicas globales; reemplazar una personal exige confirmación y revocación atómica de la anterior. El vínculo caduca al cerrar la sala o a las 8 horas, lo que ocurra antes, y se revoca si se pierde la autorización responsable en una sala protegida o la membresía propietaria de una TV personal. Renovarlo requiere un desafío nuevo. Revocar o caducar no adjudica el resultado del juego: se aplica `onLoss` del modo, subordinado a la política temporal durante una ronda sin resolver. El cambio de vista de una TV personal afecta solo a su jugador; neutralizar una comparación de reacción puede afectar a toda esa ronda. + +Una sesión de pantalla solo puede negociar `game.*` v2 para `surface: "shared-display"` de la partida vinculada. No recibe `RoomView`, `room.sync`, `presence.update`, chat, avisos personales ni lista de miembros, y se rechazan `room.command`, `game.command` y consultas de recibos personales. El servidor deriva sala/partida del vínculo, revalida autorización en la entrega y cancela la suscripción al revocar. En espera, reconexión o error la pantalla borra la proyección anterior; no muestra identificadores de menores ni texto libre de participantes. + +### Preparación TVBoard, QR de entrada y enlace de mando (propuesta v2) + +Son dos finalidades distintas: `tvboard-entry` admite personas en una mesa presencial sin invitaciones; `controller-link` continúa el acceso de un jugador invitado y enlaza exclusivamente su TV y móvil. Las rutas y nombres siguientes son diseño pendiente de OpenAPI, esquemas, implementación y fixtures: + +| Método y ruta propuestos | Solicitud / datos | Autoridad y efecto | +| --- | --- | --- | +| `POST /api/v2/tvboard/setups` | Juego/versiones públicos seleccionados y perfil `TVBoard` → preparación, QR y caducidad | Contexto TV con origen/CSRF/límites; no crea sala, anfitrión ni membresía. Solo juegos aptos para contexto público. | +| `POST /api/v2/tvboard/setups/resolve` | Token de preparación → selección pública apta | No consume la preparación ni entrega datos privados. La confirmación usa `POST /rooms` con `tvBoardSetupToken`. | +| `POST /api/v2/rooms/{roomId}/screen-entries` | `issuedAt`, `expectedEntryRevision` → acceso QR nuevo/rotado | Anfitrión de mesa `screen-qr` en espera con TV vinculada; token cifrado recuperable por contexto autorizado durante vigencia. No crea invitación. | +| `POST /api/v2/screen-entries/resolve` | Token de entrada QR → ficha de juego/mesa apta | Sin consumo ni lista de participantes; la entrada usa `POST /memberships` con `screenEntryToken` y permisos actuales. | +| `POST /api/v2/displays/controller-links` | Invitación introducida en Smart TV → desafío/QR personal y ficha apta | Contexto TV; no consume usos ni crea otra sala/plaza. Conserva contexto pendiente cifrado con caducidad corta. | +| `POST /api/v2/controller-links/resolve` | Desafío de enlace → flujo pendiente de juego y acceso | No entrega el token de invitación en claro ni acceso a la partida. Finalidad/contexto acotados. | +| `POST /api/v2/controller-links/{challengeId}/confirm` | `issuedAt`, token de desafío y confirmación desde móvil → recibo, membresía y vínculo de TV personal | Sesión humana elegible; revalida/consume invitación, crea o recupera membresía y vincula pantalla atómicamente. En miembro actual no vuelve a consumir invitación. En protegidas requiere autorización responsable. | +| `POST /api/v2/rooms/{roomId}/presentation-changes` | `issuedAt`, `targetProfile`, `expectedPresentationRevision` → recibo de solicitud y `operationId` | Miembro humano solicita su propio cambio compatible; el servidor deriva membresía y vínculo. Para TV personal → `Mobil`, aplica barrera y revoca su TV al completar. Conserva plaza/recibos. El perfil temporal puede diferir la aplicación hasta entre rondas. | +| `POST /api/v2/rooms/{roomId}/control-transfers` | `issuedAt`, `matchId`, `expectedControlGeneration`, `targetConnectionId`; si cambia perfil, `targetProfile` y `expectedPresentationRevision` → recibo de solicitud y `operationId` | Adquisición/transferencia de mando propio a conexión autenticada del mismo principal; valida renderer y punto seguro. No acepta actor ni membresía ajena. Generación inicial 0 sin mando; adquirir incrementa bajo barrera. | +| `GET /api/v2/rooms/{roomId}/operations/{operationId}` | Estado actual `pending\|applied\|rejected\|cancelled`, revisión de operación y generaciones resultantes cuando corresponda | Propietario de la solicitud; reautoriza datos. Si perdió acceso conserva solo resultado mínimo propio, sin snapshot privado. | + +Preparar el QR no otorga derechos ni reserva plaza. En la creación presencial, confirmación autorizada del anfitrión, sala `waiting`, membresía, vínculo común y acceso QR se confirman juntos. El QR de preparación se sustituye por el de entrada; ambos tienen finalidades/generaciones distintas. La mesa es privada y no emite ni consume invitaciones. El QR de altas sirve durante espera para varias personas, sujeto a capacidad descontando plazas virtuales, permisos, caducidad y límites; reintentar desde una membresía actual no consume otra plaza. Inicio, cierre, transferencia de anfitrión o revocación/expiración de TV invalidan ese acceso, bajo el mismo bloqueo que las altas. + +Los tokens QR usan aleatoriedad criptográfica de al menos 128 bits, finalidad y hash/MAC de validación, recuperación cifrada acotada y rutas con token en fragmento seguido de POST. El alias manual de vinculación tiene el tratamiento específico de la sección anterior; no se rebaja la entropía del QR. No se registran cuerpos secretos, ni se cargan terceros/analítica; respuestas `private, no-store` y `no-referrer`. Se propone caducidad máxima de 5 minutos para QR de altas y desafíos, con cuotas/valores definitivos por fijar. La rotación conserva la sala y revoca generaciones anteriores; consultar/escaneo no renueva vigencia. Errores de token no revelan si hubo caducidad, consumo, otra sala o un usuario. + +Cambios de presentación y transferencias de mando tienen un recibo **inmutable de aceptación de solicitud**, independiente de su operación duradera, que puede permanecer `pending`. `applied` en ese recibo acredita que se registró la solicitud, no que ya cambió la pantalla o el mando; el recurso identifica la operación y la UI consulta su estado hasta terminar. Reintentar el mismo UUID devuelve el mismo recibo/operación. La operación revalida permisos, precondiciones, conexión destino y generación antes de aplicar; no altera recibos previos. Al cambiar cliente y perfil a la vez se usa una sola transferencia con ambos campos, sin dos solicitudes que compitan. Las solicitudes propias se serializan y un conflicto de generación termina rechazado; la pérdida de respuesta no repite adquisición. Su barrera drena/rechaza ingresos antiguos y entrega snapshot antes de permitir nuevas acciones, según [continuidad de mando](client-communication-profile.md#continuidad-del-mando-entre-conexiones). + +La operación conserva `expiresAt` UTC y una política de espera acotada fijada al aceptarla; consultar o reintentar no amplía el plazo. Si desaparece la membresía, cierra la sala o vence la espera sin punto seguro, termina `cancelled` sin transferir; una precondición incompatible termina `rejected`. Un worker recuperado solo puede aplicar una operación pendiente, vigente y de la generación actual. Los límites y códigos de motivo se cierran en los esquemas v2; no se publica un flujo que pueda quedar pendiente indefinidamente. + +El QR personal no puede utilizarse para altas `screen-qr`, ni el QR común para reclamar un mando personal. Confirmar una admisión nueva con la invitación revocada/agotada falla sin crear vínculo. Si el principal ya conserva una membresía autorizada, se valida ese acceso para vincular su propia TV y no se vuelve a consumir la invitación. Un timeout tras commit se resuelve consultando/reintentando el mismo comando, nunca admitiendo a otra persona o regenerando usos. Las tareas pendientes revalidan autorización. Tras confirmar, el móvil controla su plaza y la TV solo recibe la proyección pública; cerrar la suscripción visual no elimina la membresía ni reabre la invitación. + +La distribución `game.*` v2 negocia perfil y contrato por suscripción: `Desktop`/`Mobil` reciben `player-full`, el mando `TVBoard` recibe `player-controller` y cada TV recibe `public-board`. El servidor deriva finalidad, alcance y destinatario del vínculo y la sesión; comandos y recibos pertenecen al principal autorizado, nunca al dispositivo elegido por el cliente. La [sección de mensajes](../game-engine/multi-device-profile.md#4-mensajes-de-partida-v2) especifica tuplas, barreras, outbox, reautorización y cambio de presentación. No se añade un broadcast de estado privado ni se reutiliza v1 como si entendiera estos campos. + +El recorrido de invitación y TV personal exige `Mobil` y la opción «Jugar solo en el móvil». La solicitud de cambio es idempotente y recuperable; no consume usos de invitación, abandona la sala ni revoca TVs ajenas. Un cambio pendiente conserva la fuente temporal de la ronda actual. Su aplicación revalida membresía/versión y publica la nueva generación; el móvil negocia el contrato completo y descarta entregas del anterior. Reabrir otra pestaña para leer no se interpreta por sí solo como petición de desvincular TV. + +El [perfil de sincronización v2](../game-engine/synchronization-profile.md) propone `time.probe`/`time.sample` y `time.ready`/`time.readiness` como control técnico. El estado temporal va en `game.snapshot.context.timing` y los eventos `round.*` en `game.snapshot.events`, ligados a época, generación, revisión y suscripción; no son sobres independientes. Los plazos y resultados se derivan en servidor. Respuestas privadas se registran con ingreso confiable duradero y se evalúan tras una recogida acotada; un timestamp cliente o el orden de commit no decide ganador. Los detalles y límites requieren esquemas/binding nuevos: no son mensajes admitidos hoy por v1. + +La app Flutter futura es **solo de controles**: usa las mismas acciones, ofertas, snapshots `player-controller` y consultas de recibos que el mando web. Su runtime/renderer se declara en negociación, sin elegir actor o destino de TV; se rechazan `player-full` y `public-board` para Flutter. El paso a móvil completo abre `Mobil` web y coordina autenticación y transferencia de mando/presentación, conservando membresía y recibos. El [perfil de comunicación](client-communication-profile.md#5-acciones-e-información-en-ambos-sentidos) describe la secuencia completa y propone `POST /api/v2/realtime/tickets` como emisión técnica ligada a desafío WSS, sesión/vínculo y generación. Esa ruta no es una mutación de juego, no sigue un recibo de jugada ni existe hoy. Se implementa antes de ofrecer la app, junto a su perfil de acceso nativo; ninguno reemplaza las validaciones de admisión/QR. + ### Chat, avisos y seguridad | Método y ruta | Datos | Acceso | @@ -132,7 +197,9 @@ Las listas móviles como salas públicas no prometen un snapshot transaccional e ## 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. +Esta sección conserva el binding v1 previsto. El [binding común futuro para web y Flutter](client-communication-profile.md#2-transporte-propuesto) separa transporte WSS de mensajes de aplicación, con autenticación inicial y renderer negociado; sustituirlo requiere contrato ejecutable y pruebas, no cambiar el nombre de una librería cliente. + +Eventos en namespace principal: `platform:message` y `game:message`. El segundo mantiene `protocolVersion: 1` y su propio sync en el contrato vigente. La pantalla compartida requiere negociación futura de `game.*` v2 y un contexto de dispositivo restringido; no se conecta al contrato v1 fingiendo ser observador. 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: @@ -146,6 +213,8 @@ Antes de suscribirse se envía un mensaje de negociación independiente: 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. +Para una pantalla vinculada, el binding futuro negocia `platform.hello` con `supportedVersions: [2]` y el contexto de dispositivo. Su `platform.welcome` v2 identifica `connectionKind: "shared-display"`, `connectionId`, `serverTime`, alcance/generación del vínculo derivados y límites; no incluye `safetyRevision` de una cuenta inexistente. Solo admite sincronización/cancelación de su partida por `game.*` v2 y control técnico de conexión/reloj negociado (`time.*` propuesto). Cualquier `platform.*` de sala, chat, presencia, avisos o comandos se rechaza sin datos. La sesión queda ligada al `roomId` autorizado en servidor, no a un identificador pedido por el cliente. Una única marca `serverTime` no sustituye los intercambios de sincronización y medición. + 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 | @@ -178,7 +247,7 @@ El servidor no comparte suscripciones personales entre usuarios. Las invalidacio ## 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. +`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. En el perfil futuro incorpora `modeId`, `entryMethod` y perfiles disponibles; seleccionar presentación no cambia esos permisos. 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. @@ -251,7 +320,7 @@ Al reconectar: obtener sesión/capacidades actuales, negociar plataforma, sincro ## 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. +El [modelo de errores compartido](shared-data-models.md#2-núcleo-común-del-error) define `ErrorData` idéntico para Go, TypeScript/Svelte y Dart/Flutter. HTTP lo envuelve en [Problem Details, RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html): `type`, `title`, `status`, `correlation` y `error`. Los campos `code`, mensaje, resultado y recuperación están **solo** en `error`; no se duplican planos con valores discrepantes. La URN se deriva del código y debe documentarse antes de publicar. No hay mensajes de excepción, payloads privados ni identificadores de cuentas ajenas en errores. | HTTP | Códigos y uso | Recuperación | | --------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | @@ -262,20 +331,34 @@ HTTP usa [Problem Details, RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html | 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. | +| 403 / 409 | `DISPLAY_MODE_UNAVAILABLE`, `DISPLAY_LIMIT_REACHED`, `STALE_DISPLAY_REVISION` | Elegir modo compatible o refrescar listado; sin datos ajenos. | +| 409 | `STALE_CONTROL_GENERATION`, `STALE_PRESENTATION_REVISION` | Recuperar recibo/operación propia y snapshot; una nueva adquisición requiere intención explícita y generación vigente. | +| 404 / 409 | `DISPLAY_CHALLENGE_UNAVAILABLE` | Crear otro desafío en la pantalla; no explica qué condición falló. | | 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`. +El binding v2 usa `ErrorMessagePayload { correlation, error }` en `platform.error`/`game.error`, sin fingir estado HTTP. La tabla de Socket.IO v1 mantiene su forma antigua hasta implementar/versionar el puente. Rechazos definitivos de sala/chat llevan el mismo `error` en recibo; fallos previos o transitorios usan mensaje de error. Una operación incierta usa `error.outcome: unknown` y `recovery.action: resolve-pending`, sin afirmar rechazo ni generar otro UUID. `CHAT_NOT_ELIGIBLE` no detalla estados de otros usuarios. Los errores de negociación usan el mismo núcleo cuando el binding admite el modelo; si no hay versión común, solo se entrega diagnóstico público mínimo y se cierra. ```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" + "correlation": { + "requestId": "f8aa2fa1-aec8-41c8-a27b-644e999df03a", + "commandId": "30c474dd-030a-4e0e-883f-c8a6d636c8c9" + }, + "error": { + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { "key": "errors.roomFull", "args": {} }, + "recovery": { "action": "change-selection" }, + "violations": [] + } } ``` diff --git a/docs/platform/architecture.md b/docs/platform/architecture.md index db0c9b3..7f45fee 100644 --- a/docs/platform/architecture.md +++ b/docs/platform/architecture.md @@ -1,8 +1,8 @@ # 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. +**Estado:** actualizado el 6 de octubre de 2026. El usuario fija tres capas —API Go, servidor/motor propio Go y frontend Svelte/TypeScript— para `games2`. La dirección tecnológica está decidida; la propuesta de monolito modular, persistencia, motor y nuevo transporte requiere implementación y pruebas. El [handoff](../HANDOFF-2026-10-06.md) distingue la base ejecutable de los contratos futuros. -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. +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. Node/Socket.IO/PostgreSQL corresponden a la aplicación original `games` v1, no a una producción de `games2`. Este repositorio tiene una base Go/Svelte con salud, bootstrap y catálogo; aún carece de motor, identidad, PostgreSQL y tiempo real. ## 1. Las tres capas @@ -10,10 +10,24 @@ El [modelo de plataforma](../platform-spec-v2.md), sus [políticas](product-poli | -------- | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | 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. | +| Servidor | Casos de uso, autorización de dominio, cuentas, salas, protección, motor, transacciones, plazos, moderación y publicación. | Go. | 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. +La [localización](localization.md) cruza las tres capas: el frontend presenta claves y formatos según preferencia; la API negocia idioma y etiqueta cada respuesta; el servidor conserva eventos, avisos y argumentos estructurados, sin convertir frases traducidas en reglas o datos autoritativos. La identidad visual y los tokens están pendientes de definición; las pantallas futuras deberán admitir expansión de texto y varias escrituras. + +El [perfil multidispositivo](../game-engine/multi-device-profile.md) añade presentaciones `TVBoard`, `Desktop` y `Mobil`, con clientes `player` y `shared-display`. El servidor distribuye por suscripción y contrato las proyecciones `player-full`, `player-controller` y `public-board`. La API conserva vínculos duraderos de pantalla común por sala o TV personal por membresía y distingue acceso QR de mesa de enlace personal tras invitación. El cambio a móvil completo conserva actor/recibos y sustituye la suscripción. Go mantiene autoridad sobre acciones y tiradas; no se envía estado privado para filtrarlo en el televisor. + +La pantalla puede ser una TV no smart: el renderer y la conexión residen en Fire TV, un receptor Chromecast, Google TV/Android TV o un ordenador por HDMI, según una vía verificada. Esos adaptadores se concretan en el [perfil de compatibilidad](../game-engine/multi-device-profile.md#televisores-y-dispositivos-externos); no modifican plazas ni permisos. El alcance gráfico decidido es **2D, incluida perspectiva, con sprites y animaciones sencillas**, sin motor 3D, Three.js, iluminación o efectos avanzados. El usuario cuestiona el peso de Phaser y pide comparar Konva con Motion y su motor propio de `vicen`. Evaluar primero reutilización del motor propio para una presentación DOM; Konva queda condicionado a necesitar objetos/capas Canvas, y Phaser como alternativa de mayor alcance. La selección requiere pruebas de portabilidad, tamaño y rendimiento. Un adaptador conserva las barreras del coordinador visual propio y Svelte la interfaz/controles. Go sigue decidiendo reglas, azar y resultado. WebGL no es una dependencia global: cada renderer publica sus requisitos; Canvas tampoco se exige para todos los juegos. Phaser 4 y Phaser Editor 5 son motor y herramienta de edición respectivamente; el Canvas de Phaser está deprecado. Ninguna de estas bibliotecas acredita soporte universal en TV/Cast. La integración gráfica y el soporte Cast/apps de TV siguen pendientes de decisión, implementación y pruebas. + +El [perfil temporal](../game-engine/synchronization-profile.md) propone reloj por época, mediciones acotadas y rondas con preparación, apertura, recogida y resolución. Los ingresos confiables deben persistirse antes de que el cierre ignore una respuesta; colas y commits no ordenan la reacción humana. Varias réplicas requieren autoridad temporal y fencing coherentes con la unidad de trabajo. Este perfil y su compensación siguen pendientes de esquemas, implementación y ensayos, separados de la simple recuperación por revisión. + +El [perfil de comunicación web/Flutter](client-communication-profile.md) prepara una futura app independiente **solo de controles**, además del frontend web Svelte. Separa runtime de presentación, propone HTTPS/WSS común y SDK TypeScript/Dart sobre esquemas/fixtures neutrales. Flutter representa únicamente `player-controller` de `TVBoard`, con controles propios/declarativos compatibles y las mismas acciones al servidor; tablero y vistas completas permanecen en la web. «Jugar solo en el móvil» abre `Mobil` web mediante autenticación y transferencia de mando al mismo principal, conservando plaza/recibos. La app no ejecuta reglas Go ni bundles JavaScript por inferencia. Sesiones web/nativas tienen adaptadores de acceso distintos hacia la misma autorización. Implementar la app se deja para después de los contratos y el primer cliente web. + +Los [modelos compartidos](shared-data-models.md) tienen fuente JSON Schema y bindings generados Go/TypeScript/Dart. API, clientes y tareas usan DTOs públicos compatibles sin compartir entidades de base de datos ni clases de UI. `ErrorData` conserva la misma estructura e información en HTTP, mensajes y recibos; la causa interna y los datos de correlación de entrega tienen límites separados. Los bindings estructurales se complementan con validadores de recepción y relaciones, no se consideran prueba de autorización. + +El [perfil de jugadores virtuales](../game-engine/virtual-player-profile.md) propone controladores IA como tareas del servidor. Su soporte e instrucciones versionadas pertenecen a las propiedades de cada juego; el adaptador recibe solo la proyección del asiento y propone acciones fuera de la transacción. El servidor deriva el actor de la instancia autorizada y revalida el comando por el mismo caso de uso. Tareas y decisiones son duraderas; ni una llamada a modelo ni su memoria se incorporan a las funciones puras de reglas. Proveedores y credenciales se configuran en infraestructura, no en el paquete. Esta capacidad aún no está implementada. + ```mermaid flowchart LR USER[Navegador] --> PROXY[Proxy HTTPS] @@ -40,6 +54,8 @@ Go proporciona compilación nativa y mecanismos de concurrencia adecuados para s 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. +El [objetivo de capacidad de 100.000 jugadores simultáneos](capacity-and-scaling.md) distingue personas, conexiones, comandos y entregas. Desde el primer juego se fijan estado duradero, recibos, límites, métricas y recuperación independiente del proceso; el broker y las réplicas se añaden cuando la carga lo exija. Esta arquitectura permite preparar varias réplicas y una autoridad transaccional por sala, pero aún no implementa ni acredita ese objetivo. La primera topología de un proceso es una fase de desarrollo, no una capacidad estimada. + 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 @@ -48,7 +64,7 @@ Go tampoco resuelve errores del proxy, rutas/NAT, políticas incompletas, autori | -------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | 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. | +| 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. | Dirección elegida para `games2`; implementación del motor pendiente. | | 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. @@ -96,7 +112,7 @@ Mantener HTTP JSON bajo `/api/v2`, contratos explícitos y errores de dominio de 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. +- Autenticación antes de suscribir: web con cookie/origen/CSRF y futuro cliente nativo con su sesión emitida. El navegador no añade cabeceras arbitrarias a WebSocket; el perfil común propone desafío WSS, ticket breve obtenido por HTTPS y primer mensaje de autenticación, con timeout y sin datos antes de autenticar. Ningún secreto en query string; el runtime declarado no sustituye credenciales. - 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. @@ -148,5 +164,6 @@ No existe una transacción `sql.Tx` compartible entre procesos Go y Node. Tampoc - 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. +- Para anunciar 100.000 jugadores simultáneos, superar la matriz de carga, fallos y presupuesto de [capacidad](capacity-and-scaling.md) con el número real de conexiones derivado. -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. +La dirección elegida es **frontend Svelte/TypeScript + API Go + servidor/motor Go**, con separación lógica entre API y dominio y propuesta de backend modular al inicio. Su principal coste pendiente es implementar y probar el runtime de juegos; los contratos y recursos ya diseñados sirven de referencia para ese trabajo. diff --git a/docs/platform/capacity-and-scaling.md b/docs/platform/capacity-and-scaling.md new file mode 100644 index 0000000..1c89629 --- /dev/null +++ b/docs/platform/capacity-and-scaling.md @@ -0,0 +1,75 @@ +# Capacidad y escalado — objetivo de 100.000 jugadores simultáneos + +**Estado:** objetivo de diseño propuesto el 5 de octubre de 2026, sin implementación ni ensayo que lo acredite. El sistema actual de `games2` solo sirve catálogo; este documento define qué debe construirse y medirse antes de afirmar que admite la cifra. Se complementa con la [arquitectura](architecture.md), el [modelo de plataforma](../platform-spec-v2.md) y la [API/tiempo real](api-and-events.md). + +## 1. Qué significa la cifra + +Para dimensionar se interpreta **100.000 jugadores humanos autenticados y conectados a partidas a la vez, repartidos entre muchas salas**. No significa 100.000 cuentas registradas, visitantes del catálogo, espectadores ni participantes de una sola partida. El paquete actual declara `players.max` de hasta 64; cada juego fija un máximo menor o igual. Pantallas compartidas, pestañas adicionales y conexiones de recuperación se cuentan por separado. + +La cifra se convierte en requisito aceptado solo cuando producto fije la mezcla real de juegos, tamaño de sala, horas pico, región, presupuesto y duración exigida. Mientras tanto se ensayan varios perfiles. Ni Go ni WebSocket ni añadir réplicas demuestran por sí solos esa capacidad. + +| Escenario de cálculo, no capacidad medida | Magnitud resultante | +| --- | --- | +| 100.000 jugadores / 4 por sala | 25.000 partidas activas. | +| 1,25 conexiones personales por jugador + una TV en 20 % de esas salas | 130.000 conexiones simultáneas aproximadas. | +| Un latido por jugador cada 15 s | Unas 6.667 entradas/s antes de reintentos. | +| Una acción por jugador cada 30 s de media | Unas 3.333 transacciones de juego/s; con cuatro destinatarios, unas 13.333 entregas/s, sin contar TV, chat, errores o sincronizaciones. | +| Avisos consultados por HTTP cada 30 s con 100.000 clientes activos | Unas 3.333 lecturas/s adicionales. | + +Estas son **hipótesis de prueba**, no una predicción de uso. También se ensaya pico de reconexión y de actividad superior a la media. El tamaño real de cada mensaje, la proyección privada por jugador y el reparto de títulos determinan CPU, memoria, tráfico y coste. + +Las 13.333 entregas/s usan cuatro destinatarios por acción como escenario aislado; no incluyen automáticamente las 1,25 conexiones personales por persona del cálculo anterior. El dimensionado final cuenta suscripciones efectivas, no solo asientos. Con 8 KiB por entrega, ese escenario produciría aproximadamente 104 MiB/s de payload antes de TLS y otros mensajes, sin que ello acredite capacidad de red. Un quiz sincronizado puede concentrar 100.000 respuestas en un segundo aunque su media sea baja: se ensayan esas ráfagas junto a la barrera de cierre. Tamaños, duración y proporción de rondas sincronizadas deben medirse, no asumirse del ejemplo. + +El escenario de 130.000 conexiones supone TVs comunes; no incluye una TV personal por jugador invitado. El [perfil multidispositivo](../game-engine/multi-device-profile.md) obliga a fijar ambas proporciones por separado. `Desktop`/`Mobil` consumen proyección completa, el móvil `TVBoard` consume mando y las TVs la proyección pública: tener tres contratos no triplica por sí solo personas ni comandos. Sí cambia CPU de proyección, bytes y fanout por suscripción, con vínculos/revocación duraderos y cuotas independientes. + +Las plazas del [perfil virtual propuesto](../game-engine/virtual-player-profile.md) se miden aparte de los 100.000 humanos: no se cuentan como personas ni exigen una conexión personal de navegador, pero generan trabajos, comandos y proyecciones. La mezcla de carga debe fijar porcentaje de IA, decisiones simultáneas, tiempo/cómputo y, si utiliza modelos externos, cuotas y coste. Sus colas y concurrencia se acotan fuera de las transacciones SQL; la cifra de humanos no acredita por sí sola capacidad para cualquier número de agentes. + +## 2. Prepararla desde el primer juego sin pagar el clúster ahora + +La primera entrega puede funcionar como **un backend Go modular y una base PostgreSQL**. La preparación consiste en conservar propiedades que permitan añadir réplicas sin reescribir reglas ni cambiar el protocolo de partidas: + +| Decisión desde el principio | Comprobación antes del primer juego público | +| --- | --- | +| Sala y partida identificadas por `roomId`/`matchId` estables; paquete y versión de reglas fijados, además de `modeId` cuando exista el perfil multidispositivo. | Un reinicio carga la misma partida y acepta solo sus reglas versionadas. | +| Estado de partida, sesiones, permisos, recibos, plazos y outbox duraderos. La memoria del proceso solo contiene cachés y conexiones descartables. | Reiniciar entre commit y respuesta, reenviar el mismo comando y obtener el mismo recibo sin repetir la jugada. | +| Todos los comandos entran por el mismo servicio de aplicación y unidad de trabajo, con bloqueo ordenado por sala. | HTTP, tiempo real y tarea de plazo no pueden confirmar transiciones incompatibles. | +| Proyecciones privadas construidas por destinatario; ningún evento general transporta una mano, rol o dato infantil. | Un segundo jugador no recibe datos ajenos; al habilitar TV, se añade la misma prueba para su proyección compartida. | +| Protocolo con revisiones, idempotencia, snapshot y recuperación; el socket no es la fuente del estado. | Cambiar de conexión o de proceso recupera la vista autorizada y detecta huecos. | +| Límites configurables para cuerpos, conexiones, buffers, colas y pool de DB; métricas por sala/operación y trazas sin secretos. | Sobrecarga controlada sin crecimiento indefinido de memoria ni agotamiento de conexiones SQL. | +| Presencia efímera separada de permisos y reglas; recursos estáticos públicos por digest. | Perder presencia o caché no altera resultados ni revela información privada. | + +La outbox se consume inicialmente en el mismo despliegue, con un adaptador interno sencillo. Su contrato publica referencias/proyecciones autorizadas, no llama directamente a una instancia concreta ni guarda la única copia en memoria. Así se podrá añadir un transporte entre nodos al desplegar varias réplicas. No se instalan ahora Redis, un broker, particionado de PostgreSQL o microservicios solo para anticipar la cifra. + +**Orden de crecimiento:** primero medir un proceso y optimizar consultas/proyecciones reales; después añadir réplicas Go y fanout entre nodos conservando la misma base/autoridad; finalmente dividir datos o responsabilidades solo si la base, la outbox o el tráfico medidos son el cuello de botella. Antes de cada salto se prueba corte de un nodo, revocación, duplicados y recuperación. La capacidad publicada siempre corresponde a la topología efectivamente ensayada. + +## 3. Topología que habría que implementar + +Se conserva una autoridad transaccional por sala: toda acción confirma estado, recibo y outbox en una unidad de trabajo. Varias réplicas del backend Go pueden atender salas distintas y concurrir sobre la misma base si todas usan el mismo orden de bloqueo y la misma autorización. El estado en memoria de una réplica es caché descartable; reiniciar o mover una conexión no cambia reglas ni versión de partida. La partición de salas entre procesos, si se necesitara después, debe tener propietario/generación y recuperación explícitos; no se introduce como hecho ya resuelto. + +Se separan presupuestos de **conexiones**, **comandos confirmados**, **lecturas**, **proyecciones/fanout** y **trabajo de fondo**. El balanceador y el proxy distribuyen HTTP/tiempo real entre réplicas con límites de conexiones, tiempo de drenaje y recursos del sistema operativo. Cada instancia limita memoria por conexión, buffers, colas, goroutines y pool de PostgreSQL; la suma de pools no puede agotar la base. Las rutas de catálogo y recursos estáticos pueden escalar/cachearse aparte sin almacenar proyecciones privadas en caché pública. + +La outbox duradera permanece en PostgreSQL. Un transporte interno de fanout puede avisar a los nodos que mantienen las conexiones de cada sala, pero no sustituye la outbox ni la autorización final por destinatario. Un nodo lento se desacopla mediante colas acotadas: se ordena resincronizar o se desconecta al cliente, sin retener infinitos eventos. Las proyecciones privadas no se difunden en un canal general ni se reconstruyen desde datos de otro jugador. Duplicados, huecos y reconexión siguen las revisiones/recibos del protocolo. + +La presencia y los latidos son efímeros, con TTL; **no se hace una escritura SQL por cada latido**. Los cambios útiles se agregan y entregan solo a las salas afectadas. Permisos, revocaciones, resultados y recibos sí mantienen persistencia/consistencia de dominio. Un caché de autorización solo se admite si una revocación puede invalidarlo antes de nuevas entregas. Listados y consultas no deben hacer barridos de todas las salas por petición. + +El perfil Socket.IO documentado y el futuro WebSocket Go requieren planes de clúster distintos. Si se conserva long-polling de Socket.IO, hacen falta afinidad de sesión y adaptador entre nodos; su [documentación del adaptador Redis](https://socket.io/docs/v4/redis-adapter/) indica que Pub/Sub no almacena eventos ni soporta por sí mismo recuperación de estado de conexión. Si se adopta WebSocket propio, el binding versionado debe implementar autenticación, fanout, cursores, reconexión y drenaje antes de sustituirlo. [Socket.IO también deja la recuperación de mensajes perdidos a la aplicación](https://socket.io/docs/v4/delivery-guarantees/). Ninguna opción de transporte cambia la transacción autoritativa. + +PostgreSQL puede seguir siendo una autoridad inicial, pero su capacidad se mide con los índices, bloqueos, tamaño de posiciones, recibos, outbox, retención y hardware reales. No se crean particiones ni réplicas de lectura por intuición; [PostgreSQL documenta ventajas y límites de particionar](https://www.postgresql.org/docs/current/ddl-partitioning.html). Si el volumen exige dividir datos, se preservan unicidad, transacciones por sala, restauración y revocación antes de migrar. Las lecturas con consistencia de revisión no se envían a una réplica atrasada como si fuese autoridad. + +## 4. Coste protocolario y ajustes necesarios + +Los valores iniciales de [API/tiempo real](api-and-events.md#7-límites-avisos-y-recuperación) incluyen presencia cada 15 s, cursores cada 15 s y consulta de avisos cada 30 s. A 100.000 conexiones, incluso el tráfico sin jugadas importa. La implementación debe medirlo y adoptar temporizadores con dispersión, agregación por nodo y envío condicionado a cambio. Antes del perfil de 100.000, el contrato de avisos debe versionarse para que `notification.changed` impulse la consulta y la lectura periódica quede como recuperación con intervalo/dispersión definidos; no basta con omitir el sondeo actual en el cliente. No se reduce la detección de revocaciones: estas se empujan e invalidan del lado del servidor sin esperar al siguiente latido. + +El fanout es **por sala y por destinatario**, nunca global por cada jugada. Una pantalla compartida recibe una sola proyección pública autorizada; cada jugador su proyección propia. Animaciones, audio y sensores se procesan en cliente y no generan comandos continuos. La entrada `motion.shake` produce a lo sumo una acción idempotente por oferta. Catálogo, recursos de juegos y traducciones se distribuyen por digest/caché pública; estado de partida, chat y datos infantiles no se cachean como contenido público. + +El [perfil temporal](../game-engine/synchronization-profile.md) añade probes con frecuencia acotada y ráfagas de respuestas al abrir/cerrar una ronda. No se escribe SQL por cada muestra de reloj; configuración, ingresos admitidos, plazos y resultados sí son duraderos. Se mide la cola antes/después del ingreso confiable, el retraso de fanout hacia cada fuente y la época al cambiar de nodo. Capacidad suficiente de sockets no demuestra equidad temporal. Las cargas deben incluir TV personal + mando, ventanas simultáneas, respuestas que compiten con cierre y cambio a móvil completo entre rondas. + +## 5. Prueba de aceptación antes de anunciar 100.000 + +1. Definir mezcla representativa: número de salas, jugadores por juego, conexiones por persona, pantallas compartidas, latidos, acciones, chat permitido, reconexiones y tamaños de mensajes. Registrar generador, datos semilla, hardware, región, versiones y presupuesto. +2. Probar escalones de 10.000, 25.000, 50.000 y 100.000 jugadores conectados, con rampa, duración sostenida y ráfagas. Ejecutar acciones reales del motor que escriban PostgreSQL, creen recibos, proyecten vistas privadas y publiquen outbox; una prueba de sockets vacíos no cuenta. +3. Medir p50/p95/p99 de conexión, acción confirmada, entrega y resincronización; tasa de error/reintento, retraso de outbox, presión de locks/pool, CPU, memoria, red, coste y bytes por destinatario. Contrastar con los [objetivos de operación](../platform-spec-v2.md#10-operación-rendimiento-y-experiencia). +4. Provocar pérdida de nodo, proxy y transporte de fanout, corte de base, despliegue con drenaje, revocación de permiso, clientes lentos y reconexión masiva con jitter. Ninguna caída duplica jugadas, filtra vistas o deja una pantalla revocada recibiendo datos; los clientes recuperan estado mediante snapshot/cursor. +5. Repetir al superar límites de recursos y documentar degradación controlada: rechazar nuevas conexiones/comandos con recuperación clara antes de agotar memoria o bloquear todas las salas. Publicar capacidad observada con margen operativo y el coste del entorno probado. + +**Criterio de salida:** 100.000 personas jugando y el número de conexiones derivado del perfil acordado durante la duración fijada, con los SLO y garantías de privacidad/consistencia cumplidos bajo carga y fallos. Hasta obtener ese resultado, la cifra es un objetivo de ingeniería, no una capacidad del producto. diff --git a/docs/platform/child-safety.md b/docs/platform/child-safety.md index e3156cf..2b3b360 100644 --- a/docs/platform/child-safety.md +++ b/docs/platform/child-safety.md @@ -87,6 +87,7 @@ El vínculo con responsable requiere el flujo verificable y revocable definido e | 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. | +| Pantalla compartida | Solo proyección pública declarada por el juego. Pantalla común autorizada por anfitrión; TV personal por su propietario y, en protegidas, responsable autorizado. No muestra alias/avatares identificables, chat, texto libre, roles ni cartas secretas. Revocación y reconexión fallan cerradas. | | 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. | @@ -96,6 +97,10 @@ El vínculo con responsable requiere el flujo verificable y revocable definido e 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. +El [perfil multidispositivo](../game-engine/multi-device-profile.md) trata la TV como dispositivo pasivo, sin cuenta, plaza ni autorización social. Como cualquiera presente puede ver la pantalla física, la proyección debe ser segura aun si hay personas fuera del grupo autorizado delante de ella. El servidor no entrega la vista de un jugador o anfitrión y corta la suscripción al terminar o revocar el vínculo. No se publica un juego con pantalla compartida hasta probar todos sus campos y eventos con salas protegidas. + +El QR de mesa solicita entrada sin invitaciones; el QR de mando personal continúa una invitación convencional y vincula únicamente la TV de esa membresía. Ninguno acredita edad, responsabilidad o contacto autorizado. Crear una mesa protegida sigue requiriendo anfitrión responsable y todas sus relaciones vigentes. Pasar de mando a móvil completo conserva esas comprobaciones y solo recibe los datos privados propios; revoca la TV personal sin abandonar la partida. El [perfil temporal](../game-engine/synchronization-profile.md) tampoco publica mediciones o tiempos privados de otros participantes al tablero. + 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. @@ -129,6 +134,7 @@ La [matriz de conservación](product-policies.md#9-datos-conservación-y-borrado | 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. | +| TV vinculada a sala protegida, revocada o reconectada | Solo vista común segura; no reaparecen secretos, chat o identidad infantil. | | 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. | diff --git a/docs/platform/client-communication-profile.md b/docs/platform/client-communication-profile.md new file mode 100644 index 0000000..1b84d72 --- /dev/null +++ b/docs/platform/client-communication-profile.md @@ -0,0 +1,141 @@ +# Clientes web y Flutter: comunicación bidireccional — propuesta v2 + +**Estado:** diseño del 6 de octubre de 2026. El usuario decide que los controles móviles pueden usar la web de la plataforma y, posteriormente, una app independiente Flutter **solo de controles**. El tablero y las vistas completas se representan en la web. Se prepara el contrato para ambos; no se desarrolla ahora una app ni un transporte nuevo. Complementa la [API/eventos](api-and-events.md), la [arquitectura](architecture.md), el [perfil multidispositivo](../game-engine/multi-device-profile.md) y la [sincronización temporal](../game-engine/synchronization-profile.md). Los esquemas y binding v1 siguen sin cambios. + +## 1. Modelo común, presentación y runtime distintos + +`presentationProfile: TVBoard|Desktop|Mobil` decide la vista; `surface: player|shared-display` decide el papel autorizado. `clientRuntime: web|flutter` declara el cliente disponible para compatibilidad, sin conceder permisos. El móvil web puede representar el mando `TVBoard` (`player-controller`) o la vista completa `Mobil` (`player-full`). Flutter representa exclusivamente `player-controller` dentro de `TVBoard`, tanto con TV común como personal: controles por fase y datos privados necesarios para decidir, sin tablero completo. La TV web conserva `public-board` pasivo. Flutter no implica otra cuenta, plaza, servidor o reglas y no ofrece `Mobil` ni `Desktop` completos. + +El servidor Go sigue siendo la autoridad. Cada cliente transforma una interacción en una acción semántica, valida/decodifica respuestas y representa su proyección. Las reglas, ofertas, secretos, azar, puntuación y recibos se deciden en el mismo servicio. La app no comunica jugadas directamente a la TV: ambas conexiones pasan por el servidor, aunque los dispositivos compartan WiFi. + +Se separan adaptadores de transporte/autenticación, SDK de protocolo y renderer del juego. Los SDK TypeScript y Dart comparten esquemas de datos y fixtures, no código ejecutable por suposición. No viajan elementos DOM, objetos Svelte, widgets Flutter, callbacks o código descargable dentro de una respuesta de partida. + +El [acceso mediante dispositivos de TV](../game-engine/multi-device-profile.md#televisores-y-dispositivos-externos) contempla Silk en Fire TV, Google TV/Android TV, un futuro receptor Cast propio y navegador por HDMI. El adaptador de acceso a pantalla y su motor gráfico son ejes independientes: WebGL no es obligatorio. Un receptor HTML5 puede reutilizar contratos web, pero exige su binding de sesión/Origin y ciclo de vida comprobados; una app de TV futura debe declarar su runtime real. No se añade ahora un enum o SDK por inferencia. La app Flutter continúa siendo exclusivamente de controles. + +Los [modelos de datos compartidos](shared-data-models.md) concretan esa fuente neutral: JSON Schema y DTOs generados para TypeScript, Go y Dart. En errores, ambos SDK usan el mismo `ErrorData` y conservan código, origen, mensaje/argumentos, resultado y recuperación. Un fallo de red se normaliza con origen transporte y estado incierto cuando pueda haber commit; no se reduce a una cadena distinta en cada cliente. La capa de transporte añade correlación, sin modificar el error duradero del recibo. Hay fixtures base; la integración de validadores/adaptadores en red sigue pendiente. + +## 2. Transporte propuesto + +Se propone **HTTPS JSON para acceso, catálogo, admisión/QR y gestión; WSS para partida bidireccional**, con un binding versionado común a web y Flutter. La [guía oficial de Flutter](https://docs.flutter.dev/cookbook/networking/web-sockets) documenta enviar y recibir mensajes mediante WebSocket. La selección de librería y versión se hace al implementar; no añade dependencias ahora. + +El binding futuro define ruta, subprotocolo, autenticación inicial, negociación, cierre y límites. Cada mensaje de texto UTF-8 contiene un sobre JSON completo; `realtime.*` se reserva al establecimiento de conexión y, tras autenticar/negociar, se admiten las familias publicadas `platform.*`, `room.*`, `game.*` y `time.*` según el rol. `round.*` identifica eventos visibles dentro de `game.snapshot.events`, no otra familia de sobres. Las versiones de transporte, protocolo de mensajes, proyección y renderer son contratos diferentes. No se cambia el sobre según sea Dart o JavaScript. Números, UUID, enums, fechas, ausencia/null y límites deben tener la misma interpretación en ambos. + +La [API v1 documentada](api-and-events.md#4-conexión-y-sobres-socketio) usa eventos `platform:message` y `game:message` de Socket.IO. **Socket.IO y WebSocket simple no son intercambiables**, como explica su [documentación oficial](https://socket.io/docs/v4/#what-socketio-is-not). Mientras exista aquel binding, necesita un cliente compatible; el nuevo endpoint no recibe paquetes antiguos como si fueran JSON v2. Convivir/adaptar transportes nunca crea dos autoridades para una sala. + +Una conexión WSS autenticada puede multiplexar las suscripciones autorizadas de plataforma y partida. Cada una mantiene revisión, cursor, `subscriptionId` y contrato propios. Se fijan límites de conexiones/suscripciones, tamaño, profundidad, frecuencia, buffers y colas, heartbeat, timeout y backoff con jitter. No se habilita long-polling, compresión o binario por inferencia: necesitan contrato y pruebas separados. Mensajería push futura tiene otra finalidad y no sustituye la partida en directo. + +Para Cast directo se propone que el receptor reciba su proyección pública por HTTPS/WSS desde Go, mientras el móvil sigue enviando acciones a Go. El canal Cast lanza/vincula/gestiona la pantalla y no sustituye `game.command`, recibos o autoridad de reglas. Duplicar una pestaña pública es otra vía de visualización, con demora adicional que el ping del mando no mide. Todas mantienen los mismos alcances `room|participant` y flujos QR/invitación del perfil multidispositivo. + +## 3. Autenticación web y nativa + +La web mantiene cookie opaca `HttpOnly`, `Secure`, origen permitido y CSRF. La app necesita un **perfil de sesión nativa** que la plataforma emita y revoque: token opaco presentado por `Authorization: Bearer` en HTTPS, con caducidad y recuperación/renovación documentadas, ligado a la misma identidad y políticas. Declarar `clientRuntime: flutter`, un device ID o carecer de `Origin` no acredita ser una app ni permite saltar autenticación. El servidor distingue sesiones por su registro emitido, no por `User-Agent`. + +No se incorpora una contraseña o secreto de aplicación común como credencial de todos los móviles. El perfil nativo debe definir emisión/rotación concurrente, almacenamiento protegido por el sistema operativo, logout, revocación, cambio de cuenta y pérdida del dispositivo antes de publicarse. Renovar no elimina plazos absolutos/restricciones de sesión. Si se adopta OAuth para ese acceso, el flujo nativo usa navegador externo y authorization code con PKCE según [RFC 8252](https://www.rfc-editor.org/rfc/rfc8252.html); no se da por contratado un proveedor ni por existente ese flujo. + +Para unificar la autenticación WSS sin depender de cabeceras personalizadas del navegador, se propone este intercambio técnico, pendiente de esquemas/rutas: + +1. La conexión todavía sin acceso recibe `realtime.challenge` con nonce de conexión, binding y límites mínimos. Solo puede autenticar dentro de un plazo corto; no recibe sala, cuenta o juego. +2. El cliente pide `POST /api/v2/realtime/tickets` por HTTPS con ese nonce. La web aporta cookie + origen/CSRF; la app aporta su sesión nativa válida. Una pantalla aporta exclusivamente su contexto limitado. El servidor deriva principal o vínculo, alcance y generación, y emite un ticket breve de un solo uso ligado a esa conexión y sesión. +3. El cliente envía `realtime.authenticate` con el ticket por WSS. El servidor lo consume atómicamente, revalida la sesión/vínculo y responde `realtime.authenticated`. Entonces negocia `platform.hello`/`platform.welcome` y los contratos de partida. No se permite una suscripción antes de autenticación y negociación. + +El ticket no aparece en query string, QR, subprotocolo, logs o analítica; su cuerpo y respuesta se excluyen de trazas y usan `no-store`. Las emisiones técnicas necesitan su propio intento/recuperación acotada y no se tratan como jugadas. Al reconectar se usa un ticket nuevo; un timeout de autenticación no reutiliza un secreto consumido. El ticket de TV nunca permite actuar como humano. Las cuotas y TTL definitivos forman parte del binding ejecutable, no de un valor supuesto aquí. + +La conexión queda ligada a la autorización vigente y a una vida limitada por la sesión. Logout, suspensión, revocación de tutela/condiciones o cambio de cuenta corta suscripciones afectadas y limpia cachés privadas en ambos clientes. Antes de acciones y entregas se revalidan permisos; un token o ticket válido no convierte al usuario en elegible para cualquier juego o chat. Los permisos de menores son idénticos en web y app. + +El desafío conserva identidad/generación de la conexión pendiente y el `Origin` observado por el servidor. Un ticket web solo se consume en esa conexión con origen explícitamente permitido; no se confía en un origen declarado en el JSON. Ausencia de `Origin` únicamente puede continuar con el perfil nativo acreditado por su sesión, nunca con una cookie web. Conexiones anónimas tienen cuotas y plazo antes de emitir desafío. El registro de emisión/consumo debe coordinarse entre el nodo HTTPS y el nodo WSS; perder el nodo invalida el desafío y requiere otro, sin reconstruir una autorización desde datos del cliente. Estas comprobaciones siguen las recomendaciones de [OWASP para WebSocket](https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html). + +## 4. Negociación y controles compatibles + +La negociación declara binding/protocolos admitidos, runtime/build del cliente y tuplas exactas de presentación disponibles. `game.sync.client` propone `clientRuntime` y, por tupla, `rendererId`, `rendererVersion` y `rendererApiVersion`, además de las versiones de reglas/proyección/presentación. La solicitud identifica perfil/superficie; el servidor deriva la proyección, comprueba el paquete fijado y devuelve en `game.snapshot` el contrato aceptado. Runtime/build son metadatos de compatibilidad, no autoridad sobre actor o identidad ni prueba de integridad de la app. Instalar una app nueva no cambia reglas de una partida existente. + +El paquete v2 propone `presentation.profiles[].surfaces..renderers`, con implementaciones por runtime para la misma proyección. Un renderer web puede tener una entrada JavaScript/TypeScript del paquete. Flutter solo se declara para `TVBoard` / `player` / `player-controller`; la negociación rechaza combinaciones Flutter de tablero o vista completa aunque el cliente las solicite. Su renderer de controles referencia `rendererId`, versión/API y contratos que la app incluye y registra en su build; no presupone ejecutar `.ts` ni descargar/compilar Dart arbitrario. La elección del motor gráfico web no exige un motor gráfico equivalente en Flutter. + +Para controles sencillos se puede publicar además un modelo declarativo versionado, interpretado por renderers previamente admitidos en ambos clientes: selección, botón, número, etiquetas traducibles y restricciones. Debe tener esquemas cerrados, accesibilidad y límites, sin scripts/HTML evaluables. El descriptor referencia acciones/parametrización permitidas; las ofertas de servidor siguen decidiendo qué puede ejecutarse. Los mandos privados complejos pueden requerir controles propios incluidos en la app, manteniendo su alcance de mando. La solución elegida se declara y se prueba por juego, no se supone un formulario universal que sustituya todas las presentaciones. + +`availableActions` expresa acciones legales, payloads/esquemas y precondiciones; el renderer presenta controles por fase/rol y envía una acción estructurada. El servidor no envía un botón ejecutable ni confía en que un control deshabilitado impida un comando manual. Tocar, usar teclado o agitar produce el mismo tipo de acción; los datos brutos del sensor no se transmiten salvo un perfil explícito de habilidad. + +No todos los juegos tienen controles Flutter al publicarse. El catálogo del cliente ofrece únicamente combinaciones verificadas de runtime/renderer/perfil; si falta soporte se explica y puede abrirse la web mediante un flujo autorizado. El acceso TV personal exige «Jugar solo en el móvil»: desde Flutter se abre obligatoriamente la vista web `Mobil`, mediante un paso declarado y probado que autentique al mismo principal y conserve sala/recibos. No se presupone que el navegador comparte sesión con la app ni se envía el token de sesión al navegador mediante URL. La transferencia de mando y presentación se confirma bajo la barrera de la sección 6, con snapshot actual en la web; solo entonces se revoca la TV personal. Si el destino no está preparado o el paso falla, se conserva el recorrido vigente y se recupera la operación sin crear otra plaza. + +## 5. Acciones e información en ambos sentidos + +| Mensaje/operación | Dirección | Finalidad | +| --- | --- | --- | +| `platform.hello` / `platform.welcome` | Cliente → servidor / servidor → cliente | Negociar versiones, conexión y límites después de autenticar. | +| `game.sync` | Cliente → servidor | Solicitar su contrato y vista; nueva suscripción con barrera. | +| `game.snapshot` | Servidor → cliente | Estado autorizado, contexto, fase, ofertas, resultado y eventos de su proyección. También entrega actualizaciones confirmadas; no exige sondeo continuo. | +| `game.command` | Cliente → servidor | Acción semántica con UUID estable, `controlGeneration` y precondición de la oferta. No envía actor, resultado ni estado global nuevo. | +| `game.ack` | Servidor → emisor autorizado | Recibo aplicado/rechazado; informa sobre el comando, no sustituye el snapshot. | +| `game.command-status` | Cliente → servidor | Consultar un comando incierto con su UUID. El contrato vigente responde con recibo/ack o `game.command-unknown`; no se inventa otro mensaje implícito. | +| `game.cursor` / `game.error` | Servidor → cliente | Detectar retraso/huecos y recuperación explícita, sin filtrar datos privados. | +| `game.unsubscribe` | Cliente → servidor | Cerrar su suscripción; no retira al participante de la sala. | +| `time.probe`, `time.sample`, `time.ready`, `time.readiness` propuestos | Según operación | Medición y disponibilidad técnica de la conexión autorizada. Los estados/eventos de ronda llegan en snapshots, ligados a época/ronda/contrato. | + +Ejemplo ilustrativo del mismo comando v2 enviado por web o Flutter; requiere los futuros esquemas y un juego que declare esa acción: + +```json +{ + "protocolVersion": 2, + "type": "game.command", + "matchId": "cc749a64-c8e4-4d74-a913-54b39f4c9089", + "commandId": "30c474dd-030a-4e0e-883f-c8a6d636c8c9", + "controlGeneration": 3, + "expected": { "kind": "match", "revision": 12 }, + "action": { "type": "drop", "payload": { "column": 3 } } +} +``` + +La secuencia es: el cliente recibe una oferta, guarda el sobre/UUID antes de enviar, representa «pendiente» y lo transmite. El servidor autentica/autoriza, comprueba recibo y precondición, ejecuta reglas y confirma estado + recibo + outbox. Devuelve `game.ack` al principal emisor y distribuye snapshots de esa revisión: mando privado a su móvil, vista completa a sus suscripciones compatibles y tablero público a las TVs autorizadas. Acuse y vista pueden llegar en distinto orden. TV no recibe la mano ni el recibo personal y carece de comandos. + +Esa secuencia presupone mando activo adquirido y fuente temporal autorizada. Una segunda conexión no recibe ofertas ejecutables ni el estímulo de reacción por una ruta alternativa; se aplica la sección siguiente antes de actuar. La consulta de un recibo confirmado precede a las precondiciones de generación/turno actuales, para recuperar sin repetir el efecto. + +```mermaid +sequenceDiagram + participant C as Móvil web o Flutter + participant S as Servidor Go + participant T as TV autorizada + C->>S: game.sync (perfil y contratos) + S-->>C: game.snapshot (vista y ofertas propias) + C->>S: game.command (UUID, generación, precondición, acción) + Note over S: Validar y confirmar estado, recibo y outbox + S-->>C: game.ack (recibo propio) + S-->>C: game.snapshot (proyección personal) + S-->>T: game.snapshot (proyección pública) +``` + +La retransmisión puede duplicar entregas; la identidad idempotente sigue siendo partida + principal + `commandId`, independiente de runtime, conexión y perfil. Reenviar el mismo sobre no repite su efecto. Diferentes UUID representan intenciones diferentes y se someten a ofertas/precondiciones; no se deduplican por parecido de payload. Un callback de transporte o una pulsación animada no demuestra que hubo commit. + +## 6. Cortes, segundo plano y cambio de cliente + +Ambos SDK mantienen estados de conexión, negociación, sincronización, pendiente y recuperación explícitos. Guardan de forma acotada y aislada por principal los sobres pendientes, último contrato/revisión y datos necesarios para recuperar recibos; secretos de partida y credenciales tienen otra política de caché/almacenamiento. Al cambiar de cuenta no se reproducen los comandos de la anterior. No se guarda una cola ilimitada ni se genera otro UUID automáticamente tras timeout. + +Al reconectar o volver al primer plano: reautenticar cuando proceda, negociar, obtener snapshot actual, consultar recibos inciertos y reintentar exactamente el sobre admisible. `unknown` no prueba que un envío previo no vaya a confirmarse. Una nueva intención requiere oferta actual y revisión explícita del usuario, sin alterar fecha/precondición de la intención pendiente. Se ignoran mensajes de suscripciones viejas y eventos ya aplicados. + +No se promete mantener un socket activo cuando el sistema suspenda la app. Pasar a segundo plano no equivale a retirada, y al volver se sincronizan estado y reloj. Sensores, animación, muestras y colas de input se detienen/descartan según el perfil; una respuesta de una ronda cerrada no se publica después como actual. La app futura conserva los límites/empates/neutralización del perfil temporal, sin recibir más compensación por declararse nativa. + +Usar otra sesión web/nativa del mismo principal recupera la misma membresía; autenticarse con otra cuenta no la transfiere. Cambiar cliente es una reconexión y negociación de renderer, distinta de cambiar `TVBoard` a `Mobil`. Ambas operaciones preservan recibos y respetan barreras/puntos seguros de ronda. El paso seguro se concreta mediante la generación de mando siguiente, sin presuponer almacenamiento compartido entre web y app. + +### Continuidad del mando entre conexiones + +La propuesta v2 mantiene **un mando activo por membresía humana/partida** y una `controlGeneration` duradera. Otras conexiones autorizadas pueden leer su vista cuando lo permita el perfil temporal. `game.snapshot.context.control` comunica `{ generation, canSend }` a clientes humanos; la TV no recibe ese control. Solo el mando activo obtiene ofertas ejecutables. Abrir una pestaña o negociar `game.sync` no roba el mando anterior ni incrementa la generación. + +Tras autenticar el nuevo cliente, una solicitud explícita de transferencia identifica su conexión autorizada y la generación esperada. El servidor comprueba que pertenece al mismo principal, su renderer/perfil y la membresía. Bajo la misma serialización que comandos e ingresos duraderos, resuelve o rechaza definitivamente los ingresos antiguos pendientes, incrementa la generación y cambia el mando. La nueva conexión recibe un snapshot posterior a esa barrera antes de actuar. Si también cambia presentación, ambas operaciones se coordinan en el mismo punto seguro; una ronda de reacción difiere la transferencia hasta entre rondas. + +La primera adquisición parte de generación 0 sin mando y utiliza la misma operación. La [API de transferencias](api-and-events.md#preparación-tvboard-qr-de-entrada-y-enlace-de-mando-propuesta-v2) devuelve un recibo inmutable de solicitud y una operación consultable; `pending` no habilita acciones. La confirmación inicial de entrada/listo puede coordinar esa adquisición para la conexión propia, sin deducirla del orden de conexión. Si cambia solo presentación en el mismo cliente, se conserva el mando y se aplica la barrera de suscripción; un cambio conjunto usa una sola operación. Una reconexión a otro socket requiere readquisición explícita, que la interfaz puede presentar como «Continuar aquí», sin exigir conocer los UUID del dispositivo perdido. + +`game.command` v2 incluye `controlGeneration` en su sobre y huella idempotente. Para un UUID ya confirmado se devuelve el recibo propio antes de comprobar la generación antigua. Un comando nuevo de una generación sustituida se rechaza con `STALE_CONTROL_GENERATION`; ningún paquete retrasado del antiguo cliente puede aplicarse después de la barrera. La consulta de recibos propios sigue disponible desde cualquier sesión autorizada del principal. Así, aunque el nuevo cliente no conozca los UUID guardados por el anterior, recupera el estado confirmado sin que una intención anterior aparezca después. No inventa un comando para repetirla; una acción nueva requiere decisión del jugador y oferta nueva. + +La desconexión por sí sola no retira al jugador ni transfiere mando automáticamente. Si se pierde la conexión destino durante una transferencia, se consulta su recibo y se repite una adquisición autorizada; nunca quedan dos mandos activos por un timeout. En cambio de cuenta, el nuevo principal no hereda la generación. Este registro es de plataforma; no convierte `controlGeneration` en identidad de actor, no cambia los UUID de recibos y no se aplica a las instancias IA internas. + +## 7. QR y enlaces de acceso + +La URL HTTPS de la plataforma identifica la finalidad del flujo. La web permite escanear sin instalar una app; la app puede leer ese mismo QR y continuar mediante las mismas rutas HTTPS. Los enlaces verificados del sistema pueden abrir la app instalada, con alternativa web. [Flutter documenta deep linking para Android, iOS y web](https://docs.flutter.dev/ui/navigation/deep-linking); dominio/rutas, asociación de plataforma y manejo del fragmento deben probarse en dispositivos reales. + +Se mantienen finalidades distintas: preparación de mesa, `tvboard-entry` para altas sin invitaciones y `controller-link` para la TV personal de un invitado. Un lector QR no convierte un código en sesión ni acredita edad/tutela. Solo acepta origen/ruta de plataforma permitidos, interpreta el flujo y presenta confirmación; tokens siguen opacos, breves y validados por servidor, en fragmento/POST según el contrato. No se coloca una credencial permanente en un enlace, ni se añade el secreto a query string para resolver un fallo de deep link. + +Autenticación de la app, acceso a sala y emparejamiento de TV son flujos diferentes. Redirecciones/callbacks de autenticación usan su propia correlación/finalidad y no reutilizan QR de juego. Cualquier paso web ↔ app requiere autorización del mismo principal, no copia implícita de cookies o tokens entre runtimes. Pérdida de respuesta, doble escaneo y app instalada/no instalada conservan los mismos límites/idempotencia de admisión. + +## 8. Verificación antes de habilitar Flutter + +Publicar binding y esquemas neutrales, fixtures de sobres/vistas/controladores y SDK TypeScript/Dart conformes. JSON Schema describe datos y debe complementarse con validación de relaciones, claves duplicadas en texto, rutas y límites. Las reglas Go, DTO de SDK y renderers se prueban contra los mismos vectores; generar tipos no sustituye validar mensajes recibidos. + +La matriz compara mando web y Flutter con los mismos permisos, acciones, resultados y proyección `player-controller`; errores y valores límite; versiones de app antiguas; renderer ausente; QR instalado/no instalado; sesiones caducadas/revocadas; cambio de cuenta; cortes antes/después de commit, duplicados y mensajes tardíos; suspensión móvil y reloj. Comprueba que Flutter no negocia `player-full` ni `public-board` y que «Jugar solo en el móvil» transfiere a `Mobil` web, sin perder membresía/recibos ni revocar la TV antes de completar el paso. Incluye adquisición inicial, transferencia concurrente, operación pendiente/rechazada, destino desconectado, envío tardío del mando antiguo y recuperación desde otro cliente sin sus UUID. Se prueba también intento web de obtener ticket nativo omitiendo `Origin` y reutilización de tickets entre nodos. Se exige aislamiento de secretos entre suscripciones/TVs, topes de memoria y equivalencia de accesibilidad/idiomas de controles. No se necesita instalar Flutter ahora: primero se materializan los contratos y el SDK web; la app se añade después con su perfil de sesión y controles verificados. diff --git a/docs/platform/localization.md b/docs/platform/localization.md new file mode 100644 index 0000000..d76a636 --- /dev/null +++ b/docs/platform/localization.md @@ -0,0 +1,51 @@ +# Idiomas y localización — games2 + +**Estado:** contrato de producto v1, revisado el 6 de octubre de 2026. La portada y el catálogo base ya permiten español e inglés; los flujos futuros deben cumplir esta política antes de activarse. El idioma de interfaz es una preferencia de presentación y nunca un permiso. La lengua del contenido de un juego puede formar parte de su configuración versionada, según la distinción siguiente. + +## Alcance y prioridades + +El primer conjunto completo de publicación es `es` y `en`. Se usarán etiquetas [BCP 47](https://www.rfc-editor.org/info/rfc5646/) para añadir variantes regionales, otras escrituras y lenguas nuevas. Solo aparece un idioma en el selector si navegación, permisos, ayuda, seguridad, condiciones, errores, avisos y juegos publicados cumplen la revisión de esa lengua. Una traducción automática sin revisión humana no abre un nuevo idioma de producto. + +Al entrar, la preferencia explícita de la cuenta prevalece sobre la preferencia local del navegador; después se consideran los idiomas configurados en el dispositivo y, por último, `es`. Mientras no haya cuentas, `games2` guarda la elección local y usa `navigator.languages` como pista. Cambiar el idioma de interfaz es inmediato y persistente, sin cambiar reglas, permisos o plaza. El selector nombra cada idioma en su propia lengua (`Español`, `English`). El documento actualiza `lang`; una futura lengua RTL actualizará también `dir` y exigirá revisar diseños con propiedades CSS lógicas. + +Se distingue `uiLocale` de `contentLocale`. Juegos de palabras, bancos de preguntas y estímulos cuya dificultad depende de la lengua fijan `contentLocale`, versión del contenido y variantes admitidas al configurar la sala/ronda. Cambiar el selector de interfaz no permite elegir otra pregunta, traducción más favorable o diccionario durante una respuesta. En rondas temporizadas, la variante del estímulo permanece fijada hasta el punto seguro; menús y mensajes de ayuda pueden cambiar inmediatamente. Traducciones equivalentes se validan antes de publicar; si no lo son, se declaran variantes de reglas/contenido en lugar de tratarlas como un cambio cosmético. + +Antes de indexar el catálogo público, cada página pública traducida tendrá URL propia (`/es/...`, `/en/...`), `canonical` correcto y enlaces `hreflang` entre equivalentes. La raíz actual solo demuestra la selección cliente en la fase inicial; no se considerará una estrategia SEO multilingüe terminada. Los enlaces privados a salas mantienen identificadores estables y no incorporan una lengua al token de invitación. + +El idioma de interfaz es distinto de la zona horaria. El servidor conserva instantes UTC y valores/identificadores independientes del idioma; los intervalos de ronda tienen su dominio monotónico explícito. La web usa `Intl` y el futuro cliente nativo un formateador compatible con CLDR, con fixtures comunes de números, fechas, horas, plurales y listas. Compartir semántica no exige usar la API JavaScript en Dart. El juego no concatena fragmentos traducidos para construir frases. Para mensajes complejos se adoptará un catálogo de claves con formas plurales/select tipadas antes de introducirlos. La moneda, si alguna vez existe, se trata por sus propios contratos y no se infiere de la lengua. + +Los catálogos de traducción se tratan como datos, sin HTML ejecutable ni interpolación de marcado libre. Variables y enlaces permitidos se tipan y revisan junto a la plantilla para que una traducción no altere permisos ni introduzca contenido activo. + +## Contratos por capa + +| Capa | Obligación | +| --- | --- | +| Frontend | Claves estables para navegación, estados, formularios, reglas, `aria-label`, `alt`, errores y ayuda. Carga de catálogos por idioma; fallback visible y controlado. `lang` en fragmentos con lengua distinta a la página. Ningún texto de seguridad oculto en una imagen. | +| API Go | Acepta `Accept-Language` en lecturas localizadas y devuelve `Content-Language` y `Vary: Accept-Language` cuando corresponda. Códigos de error y campos estructurales permanecen invariantes; la explicación humana se traduce. La negociación no cambia la autorización ni la respuesta privada que se permite recibir. | +| Servidor de dominio | Almacena claves y argumentos estructurados para avisos, eventos, moderación y resultados; no persiste una frase como dato autoritativo. Los envíos diferidos fijan idioma, versión de plantilla y datos permitidos en el momento de composición. | +| Paquete de juego | `metadata.defaultLocale` y `metadata.locales` enumeran catálogos versionados. Título, descripción, reglas, acciones, resultados, ayudas, alternativas de imagen, subtítulos y textos de portada se resuelven por claves. El arte con texto requiere variante localizada. | +| Publicación interna | Valida cobertura de todos los idiomas de producto, variables/plurales, enlaces, límites de longitud, accesibilidad y revisión humana antes de activar un juego. Los recursos y traducciones publicados quedan fijados por digest/versión junto al paquete. | + +La API de catálogo inicial acepta las preferencias `es`/`en` (incluidas variantes como `en-GB`) y responde con `locale` por ficha. Si faltase una traducción en esta fase, la ficha usa su idioma por defecto y lo indica en `locale`; una publicación real **no** debe depender de ese fallback para textos críticos. El frontend envía `Accept-Language` al cambiar el selector. El catálogo público podrá almacenarse en caché solo separando idioma y proyección de seguridad. Estas cabeceras siguen la semántica de [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#name-content-language). + +Los mensajes del protocolo conservan `type`, `code`, identificadores y argumentos. El [modelo compartido](shared-data-models.md) usa `LocalizedMessage { key, args }`: web y Flutter traducen la misma clave con argumentos seguros; `code` identifica la causa y no sustituye el texto traducible. Las formas v1 se normalizan mediante adaptador. Si una respuesta incluye texto humano del servidor, declara su lengua. El cambio de `uiLocale` en una conexión en tiempo real actualiza su localización, sin transición del juego ni cambio de la variante de contenido fijada para la ronda. En invitaciones y avisos se conserva la lengua elegida por quien recibe, sin filtrar su preferencia a terceros. + +El [perfil multidispositivo](../game-engine/multi-device-profile.md) exige textos de `TVBoard`, `Desktop` y `Mobil`, preparación/entrada QR de mesa, enlace personal tras invitación y «Jugar solo en el móvil». El idioma de la TV común lo elige quien puede gestionarla; no reemplaza la preferencia de cada mando. TV personal y móvil negocian sus textos sin difundir preferencias privadas. Cambiar idioma o presentación conserva plaza/recibos. En juegos temporizados, el [perfil de sincronización](../game-engine/synchronization-profile.md) fija el punto seguro: una traducción no reinicia el plazo ni revela antes una pregunta; el contenido necesita equivalencia de dificultad/lectura además de reloj común. + +La futura app Flutter **solo de controles** cumple el [perfil de comunicación común](client-communication-profile.md): mismas claves/códigos y contratos localizados del mando, con catálogos compatibles con la versión de sus controles. Se verifica `es`/`en` en web y app, incluidos controles declarativos/propios, QR, autenticación, recuperación y el paso a móvil completo en la web. Compartir datos de traducción no presupone compartir componentes Svelte/Flutter ni representar el tablero en la app. + +## Contenido sensible y límites + +No se traduce automáticamente el chat, los nombres escritos por usuarios, los reportes, las pruebas de edad ni los datos personales. Se conserva la lengua original cuando importa para moderación y se aplican las mismas prohibiciones de comunicación en todos los idiomas. Una persona menor o de edad desconocida no obtiene chat al cambiar de lengua. Ayuda, denuncia y textos de protección se revisan por personas competentes en cada idioma; si falta un texto esencial, la capacidad no se activa para ese idioma o se ofrece una ruta de ayuda comprensible. + +El buscador tratará acentos, mayúsculas y orden según la lengua sin alterar identificadores. Los límites de entrada se expresan en caracteres/gráficos y bytes según el contrato, nunca se justifican con la longitud media española. Las plantillas de SMS y correo se prueban con expansión, enlaces y accesibilidad antes de enviarse. + +## Pruebas de aceptación + +1. Una preferencia explícita sobrevive a recarga y sesión; un dispositivo sin preferencia negocia `es`/`en`; un idioma no soportado cae en `es` sin error ni mezcla silenciosa. +2. Todo el recorrido de catálogo, sala, partida, ayuda, cuenta y protección se revisa en ambos idiomas con teclado y lector de pantalla. Se verifica `html[lang]` y `lang` en textos con fallback. +3. La API devuelve ficha y cabeceras coherentes para `es`, `en-GB`, pesos `q`, idioma desconocido y fallback. Los códigos y reglas son idénticos entre lenguas. +4. El publicador rechaza claves ausentes, variables incompatibles, plurales incompletos, alternativas de imagen esenciales sin traducir y portadas con texto sin variante. +5. Diseño revisado a 320 CSS px, zoom 200 % y texto 40 % más largo; no hay recortes, botones ilegibles ni contenido fuera de pantalla. + +**Brecha conocida:** el esquema actual del paquete solo admite códigos sencillos como `es` y `en-US`. Antes de publicar una lengua con script o subetiquetas más complejas se debe ampliar el validador de paquetes, probar canonicalización BCP 47 y versionar el cambio de formato si altera compatibilidad. diff --git a/docs/platform/product-policies.md b/docs/platform/product-policies.md index 16e44c6..4eea41b 100644 --- a/docs/platform/product-policies.md +++ b/docs/platform/product-policies.md @@ -9,7 +9,7 @@ Complementa el [modelo](../platform-spec-v2.md), la [API](api-and-events.md), la - 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. +- Sin mensajes privados, voz, espectadores remotos con cuenta propia, búsqueda global de personas, amigos abiertos, torneos, ELO, apuestas, compras, publicidad o recompensas por tiempo de conexión. El [perfil multidispositivo](../game-engine/multi-device-profile.md) propone después una pantalla compartida pasiva, autorizada por sala y sin plaza de jugador; no abre acceso de espectadores. Añadir otras capacidades 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 @@ -70,6 +70,8 @@ Una cuenta infantil no necesita teléfono ni correo personal: recuperación por 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`. +El [perfil común web/Flutter](client-communication-profile.md) contempla una sesión nativa futura, revocable y ligada al mismo principal. Sus credenciales/almacenamiento y emisión se documentan antes de abrir la app, conservando los plazos y restricciones de P6; renovación y socket activo no alargan la vida absoluta. La app no concede otro permiso de juego/chat por evitar el navegador. QR y enlaces siguen finalidades de acceso/emparejamiento y no transportan sesiones permanentes. + 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 @@ -104,17 +106,21 @@ La vista del responsable muestra configuración, personas autorizadas, juegos y | 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. | +| 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 o el acceso QR de mesa. 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. | +| Invitación P4 | Solo entrada `standard`: 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. | +| Revancha | Otra sala, nuevo acceso según su método (invitación o QR de mesa) 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. +El [perfil multidispositivo](../game-engine/multi-device-profile.md) propone mesas presenciales `TVBoard` con `screen-qr`, privadas, sin emitir invitaciones; el acceso mostrado por TV caduca/rota y siempre exige permisos actuales. Sus cuotas específicas siguen pendientes; se aplican también los límites de salas/plazas y no se abre acceso ilimitado por usar QR. En una sala convencional, el jugador invitado puede usar TV personal y mando móvil mediante QR de enlace, y debe poder elegir «Jugar solo en el móvil» manteniendo su membresía. Un cambio visual no transforma la política de la mesa ni consume otra invitación. + +El [perfil de sincronización](../game-engine/synchronization-profile.md) propone preparación y recogida de respuestas con reloj de servidor. La llegada del primer paquete no es prueba de primera reacción; las carreras requieren compensación acotada, incertidumbre y empate. Una ronda con fallo temporal no se convierte en derrota por red. La base recomendada para quiz puntúa acierto, sin ventaja por velocidad de transporte; el perfil de reacción sigue experimental. Las reglas se fijan por versión antes de jugar y los cambios de presentación se aplican en un punto seguro, sin reiniciar el plazo. + 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 @@ -135,6 +141,8 @@ Preferencia de sonido y movimiento reducido, sin autoplay de audio antes de inte ## 7. Moderación, administración y juego limpio +Los [jugadores virtuales](../game-engine/virtual-player-profile.md) son una opción declarada por cada juego, con instrucciones incluidas en su paquete y perfiles publicados por el equipo. La sala identifica sus plazas IA y los humanos aceptan la configuración antes del inicio. Este soporte declarado no autoriza asistencia externa oculta para una plaza humana. Un agente usa solo la información de su asiento, carece de permisos sociales y no sustituye la tutela ni al anfitrión responsable; la práctica individual contra IA requiere un perfil de admisión explícito antes de activarse. La capacidad está pendiente de implementación. + 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. diff --git a/docs/platform/review-2026-10-06.md b/docs/platform/review-2026-10-06.md new file mode 100644 index 0000000..8df53f8 --- /dev/null +++ b/docs/platform/review-2026-10-06.md @@ -0,0 +1,57 @@ +# Revisión de documentos nuevos — 6 de octubre de 2026 + +**Resultado:** la separación entre jugador, dispositivo, proyección y autoridad de servidor es una base coherente. La revisión detectó huecos de recuperación, temporización y autorización que podían producir implementaciones incompatibles. Se han corregido los contratos documentales; **no** se han implementado estas capacidades ni demostrado equidad de reacción o capacidad de 100.000 jugadores. + +Se revisaron los perfiles multidispositivo, temporal, web/Flutter y virtual; localización y capacidad; y su coherencia con API, modelo, arquitectura, políticas, roadmap y handoff. La exploración visual permanece aplazada/no aprobada. Esta revisión complementa la del [5 de octubre](review-2026-10-05.md), sin convertir sus correcciones de diseño en funciones ejecutables. + +## 1. Estado y autoridad + +| Nivel | Qué consta | +| --- | --- | +| Decisiones del usuario | API y servidor/motor Go; frontend Svelte; juegos propios; protección transversal; IA opcional definida por juego con instrucciones; tres presentaciones; QR presencial sin invitaciones; TV personal tras invitación con paso a móvil completo; futura app Flutter. | +| Propuestas técnicas | Contratos v2 de proyección/renderer, HTTPS/WSS y tickets, mando activo con generación, relojes/rondas, controladores IA, presupuestos y límites. Deben concretarse en esquemas y pruebas antes de habilitarse. | +| Base ejecutable comprobada en código | Salud, bootstrap y catálogo Go; frontend Svelte con español/inglés. No hay motor, identidad, salas, PostgreSQL, tiempo real, IA ni Flutter ejecutables. Véase el [handoff](../HANDOFF-2026-10-06.md). | +| Evidencia existente | Verificador de contratos v1 y comprobaciones documentales. No validan las extensiones v2 descritas aquí. | + +Los perfiles especializados definen semántica; la [API](api-and-events.md) reúne rutas/recibos; [políticas](product-policies.md) fija admisión y límites de producto. No se deduce soporte de un ejemplo JSON. Una decisión expresa posterior se incorpora a sus documentos canónicos y al handoff, sin dejar recomendaciones antiguas en conflicto. + +## 2. Hallazgos y correcciones + +P1 indica riesgo de autorización, duplicación, pérdida de acciones o resultado injustificado. P2 indica ambigüedad de contrato, planificación o evaluación. Todos los estados siguientes significan **corregido en documentación; implementación pendiente**. + +| ID | Nivel | Hallazgo | Corrección y verificación necesaria | +| --- | --- | --- | --- | +| R1 | P1 | Cambiar web/app sin los UUID del cliente anterior podía permitir que una acción incierta se aplicase después de actuar en el nuevo. | [Mando activo y generación](client-communication-profile.md#continuidad-del-mando-entre-conexiones), adquisición explícita y barrera común con ingresos. Probar transferencia concurrente, envío antiguo tardío y recuperación de recibo confirmado sin mando. | +| R2 | P1 | Una segunda vista podía revelar la pregunta antes mientras se compensaba el retardo de otra TV. | [Fuente de estímulo por ronda](../game-engine/synchronization-profile.md#3-propiedades-temporales-del-juego), restricción de suscripciones nuevas y revalidación de outbox. Probar pestañas, web/app simultáneas y reconexión; no prometer prueba física de visualización. | +| R3 | P1 | Pasar a recogida/cerrar podía invalidar respuestas puntuales todavía en cola o anunciar recepción como aplicación. | [Ingreso duradero y barrera de cierre](../game-engine/synchronization-profile.md#5-protocolo-de-ronda-y-distribución); mantener precondición hasta drenar, distinguir cola de commit. Probar retraso SQL, caída antes de persistencia y lease vencido. | +| R4 | P1 | Neutralizar por cualquier reporte de lag permitía repetir rondas hasta acertar o reutilizar preguntas reveladas. | [Política de incidentes](../game-engine/synchronization-profile.md#7-cambio-de-presentación-y-comprobación) con criterios de servidor, cuotas acumuladas, estímulo nuevo y conservación de resultados resueltos. Probar desconexiones deliberadas y agotamiento. | +| R5 | P1 | Cookie/CSRF se describían como universales mientras la app necesitaba otra sesión; faltaba ligar origen observado y ticket WSS. | [Adaptadores web/nativo](client-communication-profile.md#3-autenticación-web-y-nativa) y consumo vinculado a conexión/generación. Probar ausencia/falsificación de origen, ticket cruzado y coordinación entre nodos. | +| R6 | P1 | Reintentar un trabajo IA obsoleto podía renovar su presupuesto; bloquear el agente podía dejar un reloj adjudicando derrota. | [Oportunidad duradera y fallo operativo](../game-engine/virtual-player-profile.md#4-ejecución-fallos-y-recuperación), límites totales y fencing; política obligatoria de pausa/recuperación. Probar workers concurrentes, reinicio y plazo simultáneo. | +| R7 | P2 | `round.*` se usaba sin corresponder a las familias del transporte. | Estado en `game.snapshot.context.timing`, eventos en `game.snapshot.events` y acuses técnicos `time.ready`/`time.readiness`. Crear fixtures de TV pasiva, preparación y reconexión. | +| R8 | P2 | Marcas UTC y monotónicas podían confundirse o compararse tras cambiar de proceso. | [Unidades y época explícitas](../game-engine/synchronization-profile.md#4-reloj-medición-y-autoridad), sufijo `Ms`, fechas operativas separadas y nueva época tras cambio de autoridad. Probar suspensión y failover. | +| R9 | P2 | Recibo `pending` que luego fuese `applied` contradecía recibos inmutables. | [Operaciones de presentación/mando](api-and-events.md#preparación-tvboard-qr-de-entrada-y-enlace-de-mando-propuesta-v2) separan recibo de solicitud y estado consultable. Probar reintento durante espera y rechazo por precondición obsoleta. | +| R10 | P2 | El código de 10 símbolos del QR contradecía el requisito de 128 bits; caducidad y recuperación eran ambiguas. | [Token QR y alias manual](api-and-events.md#pantallas-compartidas-extensión-futura-del-perfil-multidispositivo) diferenciados, consumo conjunto, cuotas y vigencia sin renovación por reintento. Probar ambas entradas y revocación concurrente. | +| R11 | P2 | Idioma siempre inmediato contradecía estímulos congelados y juegos de palabras; se presuponía `Intl` en cualquier cliente. | [Interfaz y contenido separados](localization.md#alcance-y-prioridades), variantes fijadas por ronda y formateadores web/Dart equivalentes. Probar que cambiar idioma no elige otra pregunta ni reinicia tiempo. | +| R12 | P2 | Go servidor figuraba pendiente y la producción original se confundía con `games2`; medias de carga parecían incluir todas las conexiones. | [Arquitectura](architecture.md) y modelo alineados con handoff; [capacidad](capacity-and-scaling.md#1-qué-significa-la-cifra) distingue entregas, suscripciones, bytes y ráfagas. Ninguna cifra es capacidad medida. | +| R13 | P2 | Redactar secretos podía interpretarse como ocultar también toda actividad. | [Distribución multidispositivo](../game-engine/multi-device-profile.md#distribución-de-mensajes-y-vínculo-entre-dispositivos) explicita la observabilidad de revisiones globales; juegos que deban ocultar actividad requieren otro contrato probado. | + +## 3. Qué falta antes de implementar/publicar + +1. Crear esquemas ejecutables v2, contrato nativo Go y fixtures comunes de mensajes, proyecciones y renderers. Mantener v1 separado y probar rechazo explícito de versiones/combinaciones no admitidas. +2. Construir el primer corte de persistencia, autorización, recibos, outbox y recuperación del [roadmap](../ROADMAP.md). Las carreras descritas necesitan pruebas de integración transaccional, no solo validación JSON. +3. Materializar operaciones duraderas de mando/presentación, invalidación de suscripciones y sesión WSS; después, los recorridos QR y la futura sesión nativa. Los registros y fallos se documentan antes de ofrecer la app. +4. Empezar el quiz por acierto en ventana, con política de incidentes. Mantener reacción compensada experimental hasta medir estimador, incertidumbre, rutas de pantalla/control y abuso. No existe garantía de ordenar exactamente pulsaciones humanas bajo cualquier conexión. +5. Fijar presupuestos de IA, plazos operativos y mezcla de carga con evidencia. Probar privacidad con juegos de información oculta y ensayar capacidad en infraestructura real antes de anunciarla. + +## 4. Verificación de esta revisión + +Comprobaciones realizadas y satisfactorias: + +- `npm run contracts:check`: 5 manifiestos, 14 mensajes, 37 casos de rechazo, 5 componentes, 52 huecos de arte, plan de presentación y 6 esquemas; comprobación TypeScript incluida. +- Revisión estructural de 13 documentos corregidos: 170 enlaces/anchors locales y 11 ejemplos JSON válidos; sin espacios al final de línea. +- Correspondencia del ejemplo multidispositivo: 6 renderers, 2 nativos, y tupla de negociación coherente con proyección/versiones. El comando web/nativo incluye la generación de mando propuesta. +- Ejemplos numéricos: los intervalos de reacción publicados producen empate y el supuesto de tráfico da aproximadamente 104 MiB/s. Son cálculos ilustrativos, no ensayos de red/capacidad. + +Estas comprobaciones no validan los nuevos contratos v2 contra un esquema, porque todavía no existe. Se corrigió un enlace interno del propio registro antes de cerrar la revisión. + +La revisión es documental; no cambia el runtime ni revalida con ella los ensayos históricos de frontend/Go del handoff. Las fuentes de industria permanecen enlazadas en sus perfiles, diferenciando documentación histórica de versiones actuales. La parte nueva de seguridad WSS se contrastó con [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html) y la recuperación de reloj con [la documentación de Go](https://pkg.go.dev/time#hdr-Monotonic_Clocks). diff --git a/docs/platform/shared-data-models.md b/docs/platform/shared-data-models.md new file mode 100644 index 0000000..9de9dc9 --- /dev/null +++ b/docs/platform/shared-data-models.md @@ -0,0 +1,138 @@ +# Modelos de datos compartidos — Go, Svelte y Flutter + +**Estado:** contrato base definido el 6 de octubre de 2026 por indicación del usuario. Hay una fuente JSON Schema, DTOs generados TypeScript/Go/Dart y fixtures comprobables. Esto no implementa sesiones, salas, WSS ni una app Flutter. Los adaptadores de error del runtime y los contratos completos de esos flujos siguen pendientes. + +## 1. Una fuente para los tres ámbitos + +Los datos que cruzan una frontera se definen en [contracts/shared/schema.json](../../contracts/shared/schema.json), con JSON Schema 2020-12. De esa fuente se generan [TypeScript](../../contracts/shared/ts/models.generated.ts), [Go](../../contracts/shared/go/models.gen.go) y [Dart](../../contracts/shared/dart/models.g.dart). No se mantienen tres definiciones manuales del mismo DTO. Los nombres JSON, tipos, campos obligatorios, ausencia/null, códigos, unidades y significado deben ser iguales. + +Un DTO compartido no es una entidad de base de datos, un componente Svelte ni un widget Flutter. Go mantiene entidades y causas internas separadas y proyecta el DTO autorizado; web y app lo decodifican y lo representan. Ningún campo del DTO acredita actor, edad o permiso. La lectura con otro lenguaje no cambia la información que recibe un mismo destinatario autorizado. + +| Familia definida | Modelos y reglas | +| --- | --- | +| Identidad y versión | `UUID`: representación canónica minúscula; `Revision`: entero entre 0 y 2^53−1, sin pérdida de precisión JSON. Un UUID no concede autorización. | +| Tiempo e idioma | `Instant`: RFC 3339 UTC con `Z`; `Locale`: etiqueta estructural BCP 47. El patrón de idioma no sustituye canonicalización, soporte real ni revisión de traducciones. | +| Presentación de mensajes | `LocalizedMessage { key, args }`; argumentos escalares acotados, sin HTML, objetos anidados ni valores secretos. Misma clave/argumentos para cada SDK; traducción según preferencia. | +| Errores | `ErrorData`, `ErrorCategory`, `ErrorOrigin`, `OperationOutcome`, `Recovery`, `Violation`; núcleo único en todos los transportes. | +| Correlación y transporte | `Correlation`, `HttpProblem`, `ErrorMessagePayload`, `CommandRejection`; contexto separado del error duradero. | +| Catálogo | `GameCard`, `CatalogPage`, `CatalogResponse`; nombres y valores compatibles con la base actual. Antes de adoptar el límite de 100 fichas se materializa paginación en servidor. | + +Los valores enteros deben seguir el rango seguro aunque Go/Dart dispongan de enteros más amplios. `NaN`/infinito no son datos JSON admitidos. Listas y mapas vacíos se emiten como `[]` y `{}`, no `null`; en Go deben inicializarse. Un campo opcional se omite; `null` solo se acepta donde el esquema lo declara, como `nextCursor`. Los instantes UTC son distintos de los intervalos monotónicos `*Ms` ligados a `clockEpoch` del [perfil temporal](../game-engine/synchronization-profile.md#4-reloj-medición-y-autoridad). + +Los modelos de cuentas, salas, admisión, operaciones, recibos completos y proyecciones se incorporarán por cortes, desde los documentos que definen su dominio. Una clase con nombre parecido no convierte esos flujos pendientes en implementados. Cada campo nuevo debe declarar destinatarios y tratamiento de privacidad además de su tipo. + +## 2. Núcleo común del error + +Todos los errores públicos usan `ErrorData`, sin un formato diferente para Svelte, Go o Flutter: + +```json +{ + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { "key": "errors.roomFull", "args": {} }, + "recovery": { "action": "change-selection" }, + "violations": [] +} +``` + +| Campo | Información que comparte | +| --- | --- | +| `modelVersion` | Versión del modelo, independiente de `/api/v2`, protocolo de juego y versión de renderer. | +| `origin` | `server`, `client` o `transport`. Una pérdida de red normalizada por el SDK no se presenta como un rechazo del servidor. | +| `code` | Identificador estable de causa pública. Nunca una frase, tipo de excepción Go/Dart/JS o código HTTP usado como sustituto de causa. | +| `category` | Clasificación común para políticas de presentación/diagnóstico: validación, autenticación, autorización, conflicto, disponibilidad, etc. No concede permisos. | +| `outcome` | Lo que se sabe sobre la operación, según la sección siguiente. Impide equiparar timeout con rechazo confirmado. | +| `message` | Clave traducible y argumentos públicos tipados. No se programa comparando frases traducidas. | +| `recovery` | Acción semántica de recuperación y `retryAfterMs` opcional. El SDK combina esa indicación con el estado local y la idempotencia. | +| `violations` | Lista de campos inválidos: `pointer` JSON Pointer, código y mensaje estructurado. Vacía si no corresponde. No devuelve el valor recibido. | + +El esquema permite códigos futuros con el mismo formato; un código desconocido recibe mensaje genérico y conserva categoría/resultado válidos para diagnóstico. Una clave de traducción desconocida no rompe la pantalla. Una categoría, acción o versión desconocida requiere fallback seguro de protocolo, sin ejecutar automáticamente una recuperación nueva. Los escritores solo emiten campos publicados; los lectores pueden ignorar extensiones opcionales desconocidas. No interpretan esos campos como permisos ni almacenan/loguean todo el objeto recibido. + +## 3. Resultado incierto y recuperación + +| `outcome` | Significado | +| --- | --- | +| `not-applicable` | Lectura, negociación o fallo sin una mutación de dominio asociada. | +| `not-applied` | Existe evidencia de que este intento no aplicó la operación. No garantiza que otro envío del mismo comando esté sin resolver. | +| `rejected` | Rechazo de negocio definitivo registrado en un recibo del servidor. Reenviar el mismo UUID devuelve ese recibo; no vuelve a ejecutar. | +| `unknown` | No se puede demostrar aplicación o rechazo. Puede haber ocurrido un commit. | + +`unknown` exige `recovery.action: resolve-pending` y, en un sobre, referencia propia de comando u operación. El cliente consulta el recibo/estado pertinente y, cuando corresponda, reenvía **el mismo sobre**. No crea otro UUID, cambia precondición ni anuncia fracaso definitivo para desbloquear una acción duplicada. Un fallo local de decodificación después de enviar una mutación también conserva incertidumbre. Autenticación usa su registro de intento específico; no se inventa un recibo de juego para recuperar credenciales. + +Las acciones publicadas son `none`, `correct-input`, `authenticate`, `complete-requirements`, `resync`, `resolve-pending`, `retry-same-request`, `change-selection`, `update-client` y `contact-support`. `retry-same-request` solo se utiliza con evidencia y una operación reintentable/idempotente. `rejected` no autoriza ese reintento automático. `retryAfterMs` es un entero no negativo, máximo 24 h en este perfil, y solo acompaña reintento/consulta pendiente; cuotas superiores necesitan otra política/versionado. + +Una nueva intención después de rechazo/revisión obsoleta exige oferta actual y decisión del usuario. Los errores no contienen comandos ejecutables, destinos arbitrarios ni nuevas credenciales. Una recomendación de recuperación no elude condiciones, protección, membresía o generación de mando. + +## 4. HTTP, mensajes y recibos + +HTTP utiliza [Problem Details, RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html), con `error` y `correlation` como extensiones propias: + +```json +{ + "type": "urn:juegoland:problem:room-full", + "title": "La sala está completa", + "status": 409, + "correlation": { + "requestId": "f8aa2fa1-aec8-41c8-a27b-644e999df03a", + "commandId": "30c474dd-030a-4e0e-883f-c8a6d636c8c9" + }, + "error": { + "modelVersion": 1, + "origin": "server", + "code": "ROOM_FULL", + "category": "conflict", + "outcome": "rejected", + "message": { "key": "errors.roomFull", "args": {} }, + "recovery": { "action": "change-selection" }, + "violations": [] + } +} +``` + +Se emite `application/problem+json`, con `status` igual al HTTP real, caché `no-store` y título público localizado cuando proceda. `type` se deriva de `code` como `urn:juegoland:problem:` más código en minúsculas y guiones; cada código necesita documentación antes de publicarse. La URN identifica el tipo y no se descarga desde el cliente. `title` es fallback humano, sin ser la autoridad de causa; los SDK usan el mismo `error`. + +Los mensajes v2 `platform.error`/`game.error` incorporan `ErrorMessagePayload { correlation, error }` dentro de su sobre negociado. No llevan un estado HTTP ficticio. Los rechazos de negocio incorporan el mismo `error` en su recibo, conservando recurso/revisiones/UUID según el contrato completo. `CommandRejection` valida solo ese fragmento; no constituye un recibo completo de sala o juego. Un rechazo necesita origen servidor y resultado `rejected`. + +El error duradero de un recibo no cambia al reintentarlo ni al cambiar idioma/dispositivo. `Correlation` pertenece a la entrega: `requestId` de intento/traza, `commandId` estable, `operationId` o `subscriptionId` cuando corresponda y esté autorizado. No se guarda una nueva traza dentro del recibo para modificarlo en cada consulta. Si la recepción ya identifica el comando, un adaptador no elimina esa correlación al normalizar el fallo. Un ID de soporte no expone stack, hosts, SQL o estado privado. + +Los [fixtures comunes](../../contracts/shared/fixtures.json) contienen el mismo núcleo `ROOM_FULL` en HTTP, mensaje y rechazo; el verificador compara igualdad. La nueva estructura reemplaza los errores planos del **borrador** de plataforma al adoptar este contrato. El [protocolo de juego v1](../game-engine/format-and-protocol.md) conserva sus sobres/códigos; cualquier puente normaliza al modelo común en el SDK sin modificar silenciosamente v1. + +## 5. Códigos, localización y privacidad + +Cada código publicado registra significado, categoría, clave/argumentos, estados HTTP admitidos y recuperaciones válidas en su contrato de dominio. No se renombra o reutiliza para otra causa. Las [categorías de API](api-and-events.md#8-errores-y-compatibilidad) siguen siendo referencia de plataforma; los juegos registran sus causas al publicar el paquete. Los códigos son extensibles, pero los argumentos/recovery deben estar documentados y probados. + +| Ejemplo | Categoría / recuperación | +| --- | --- | +| `INVALID_REQUEST` | `validation`; corregir entrada; violations solo de campos públicos del formulario. | +| `SESSION_EXPIRED` | `authentication`; reautenticar y eliminar vistas privadas. | +| `FORBIDDEN` | `authorization`; mensaje genérico sin identificar a terceros. | +| `ROOM_FULL` | `conflict`; cambiar selección; rechazo confirmado si ya tiene recibo. | +| `STALE_CONTROL_GENERATION` | `conflict`; recuperar recibo/operación y sincronizar antes de una nueva intención. | +| `RATE_LIMITED` | `rate-limit`; demora acotada; distinguir intento no aplicado de una intención anterior pendiente. | +| `CONNECTION_LOST` | `transport`; resolver pendiente si se envió una mutación; lectura reintentable si no la hubo. | +| `INVALID_RESPONSE` | `protocol`; fallback local; conservar incertidumbre de una mutación enviada. | + +Web y Flutter traducen las mismas claves/argumentos revisados de `es`/`en`; idioma de interfaz no cambia código, resultado ni recuperación. No se presentan mensajes crudos de excepciones. Las causas internas permanecen en diagnóstico restringido, fuera del DTO. No se incluyen teléfono/OTP, prueba de edad, tokens, datos de otra plaza ni payload rechazado. También `violations` evita enumerar recursos ajenos; un fallo de secreto/credencial conserva el mensaje genérico de su política. + +Los fallos de proxy, HTML inesperado, bytes inválidos, cortes y timeout se normalizan localmente con esta estructura, manteniendo `origin` y el resultado que realmente se conoce. El SDK no inventa un `requestId` servidor ni una causa de negocio a partir de un 502 genérico. + +## 6. Generación, conformidad y adopción + +```powershell +npm run contracts:generate +npm run contracts:check +go test ./contracts/shared/go +dart contracts/shared/dart/verify.dart +dart analyze contracts/shared/dart +``` + +El generador y el verificador funcionan desde la raíz. `contracts:check` detecta si un DTO generado se separó del esquema y verifica ejemplos positivos/negativos. El esquema es la fuente de estructura; las relaciones semánticas de las secciones 3/4 se comprueban también en [validate.ts](../../contracts/shared/validate.ts) y en los fixtures compartidos. Esas relaciones no se deducen solo de los tipos generados. El perfil del generador cubre únicamente las construcciones usadas en esta fuente; ampliarlo exige fixtures y revisión de las tres salidas. No es un generador general de OpenAPI ni del SDK de reglas. + +Los bindings Go/Dart son DTOs estructurales: sus tipos/decodificación **no sustituyen** rangos, enums, relaciones, límites ni autorización. La validación de esquema/semántica se verifica en las herramientas TypeScript; Go/Dart comprueban lectura/serialización de los mismos ejemplos válidos. Antes de usar los adaptadores en red deben superar también los casos negativos con validadores propios conformes. La recepción textual necesita además límites de bytes/profundidad, rechazo de claves duplicadas y Unicode inválido antes de parsear; JSON Schema sobre un objeto no detecta claves duplicadas. + +Adopción por cortes: usar los tipos compartidos para el catálogo; integrar `ErrorData` en adaptadores HTTP y errores locales web; después binding/recibos v2 y futura app con su validador. Una capacidad sigue desactivada hasta tener su camino completo verificado. Las entidades privadas nunca se serializan directamente por haber generado una clase pública. + +Comprobación inicial: 22 modelos generados sin deriva, 14 fixtures válidos y 25 rechazos de esquema/semántica; núcleo de error idéntico entre HTTP, mensaje y recibo. Go y Dart conservan los 14 fixtures al decodificar/serializar; análisis Dart y pruebas de API Go pasan. TypeScript del frontend y Svelte pasan; para Svelte se usó temporalmente configuración por defecto equivalente a las opciones de compilador actuales, porque cargar Vite intentaba escribir una caché bloqueada por el entorno. No se hizo un ensayo de binding WSS ni de una app Flutter. diff --git a/docs/platform/visual-design.md b/docs/platform/visual-design.md new file mode 100644 index 0000000..088539b --- /dev/null +++ b/docs/platform/visual-design.md @@ -0,0 +1,59 @@ +# Exploración visual — games2 + +**Estado: propuesta no aprobada.** La portada actual es un prototipo exploratorio y este documento no establece la identidad visual, los tokens ni criterios obligatorios para el equipo. El sistema de diseño y tokens se definirá en un paso posterior. + +## Carácter + +Juegoland es una **mesa compartida**: acogedora, clara y contemporánea. La interfaz debe invitar a volver a jugar con otras personas sin parecer un casino, una aplicación de apuestas ni un producto exclusivo para niños. El juego aporta la fantasía; la plataforma aporta orientación, confianza y continuidad. + +Tres decisiones reconocibles sostienen la identidad: un plano violeta profundo para el momento de encuentro; piezas geométricas de tablero, carta y dado dibujadas con superficies planas y sombras cortas; y tipografía contundente con mucho espacio para leer. La ilustración nunca representa una partida real ni promete juegos que aún no estén publicados. + +## Valores del prototipo (no son tokens oficiales) + +La tabla describe únicamente la pantalla exploratoria existente. Ningún nombre, color o medida se adopta como contrato del futuro sistema de diseño. + +| Token | Valor inicial | Uso | +| --- | --- | --- | +| `canvas` | `#F5F4EF` | Fondo general; evita superficies oscuras continuas. | +| `paper` | `#FFFFFF` | Contenido, formularios y superficies de lectura. | +| `ink` | `#252241` | Texto principal. | +| `muted` | `#5E6072` | Texto secundario, nunca información crítica con bajo contraste. | +| `violet` | `#392777` | Superficie de marca y navegación principal. | +| `violet-deep` | `#2C1D63` | Profundidad de las piezas y variantes de interacción. | +| `coral` | `#F07860` | Pieza decorativa y acentos puntuales; **no** equivale a error. | +| `gold` | `#F8B951` | Acción principal sobre violeta y ficha ilustrada. | +| `mint` | `#D4E8D2` | Tableros y superficies secundarias ilustradas. | +| `line` | `#DEDDD8` | Separadores y bordes discretos. | + +Los colores de éxito, advertencia, error y protección son semánticos y se validan por contraste sobre su superficie; no se deducen de las piezas de marca. Ningún estado depende solo del color: siempre lleva texto o icono con nombre accesible. El violeta ocupa una superficie importante por vista; el resto permanece sobrio. Se prohíben gradientes decorativos repetidos, neón, fichas de casino, monedas, trofeos de retención y muros de tarjetas idénticas. + +Se utiliza **Manrope Variable** servida desde el propio frontend, con fallbacks de sistema. Una sola familia evita cambios arbitrarios entre pantallas. Títulos entre 40–105 px según contexto, cuerpo base 16–17 px, texto explicativo 18–21 px, etiquetas al menos 13–14 px y altura de línea de lectura 1,45–1,6. La jerarquía se construye con tamaño, peso y espacio; no con mayúsculas espaciadas ni una etiqueta ornamental encima de cada título. El contenido corriente limita la línea a unas 65–75 letras. + +El logotipo usa una única base violeta con tres piezas geométricas. Su nombre se escribe `juegoland` en minúsculas. La marca de un juego puede tener arte propio, pero no sustituye la navegación ni los controles de seguridad de la plataforma. + +## Composición y componentes + +- **Página:** ancho máximo de 1540 px, margen interior fluido, lectura alineada a la izquierda. Nunca se fija el ancho de toda la app al tablero. La portada separa entrada y catálogo; una sala separará información de personas, estado de partida y acciones. +- **Móvil primero:** navegación, catálogo, diálogos y configuración funcionan desde 320 CSS px, con teclado virtual, cambio de orientación y zoom al 200 %. A 760 px la portada pasa a una columna; la escena visual queda después del mensaje. Los controles esenciales no desaparecen por falta de espacio. +- **Acciones:** primaria rellena y descriptiva; secundaria de contorno; destructiva claramente diferenciada y acompañada de texto. El área táctil objetivo de producto es de al menos 44 × 44 CSS px. El foco visible atraviesa todos los controles, incluidos los de un juego. +- **Tarjetas de juego:** portada del paquete, título, resumen y jugadores. El estado de disponibilidad es explícito. No se presenta una ilustración genérica como portada oficial de un juego. El catálogo vacío explica qué ocurrirá; fallo de red indica cómo reintentar. +- **Sala y partida:** la cabecera de plataforma mantiene identidad, salir, ayuda, conexión y permisos. El juego puede ocupar el espacio central y variar su arte sin recolorear confirmaciones, avisos o moderación. Estado pendiente, confirmado y reconectando son distintos. Ninguna animación adelanta visualmente una transición aún no confirmada por el servidor. +- **Diálogos:** tamaño adecuado a viewport, contenido desplazable y foco contenido. En móvil se prioriza una hoja o pantalla completa cuando el formulario no cabe; nunca una ventana de escritorio comprimida. + +Los componentes son tokens y patrones de interacción, no una biblioteca de tarjetas intercambiables. Una nueva pantalla debe explicar su acción primaria y su estado sin depender de la ilustración. + +## Arte, recursos y movimiento + +La ilustración de plataforma usa SVG/CSS y formas legibles (tablero, carta, dado, ficha) con un único propósito visual por escena. Cada paquete de juego proporciona `cover`, `thumbnail` e `icon`, según el [formato del motor](../game-engine/format-and-protocol.md). Para catálogo se adopta relación 4:3: portada recomendada de al menos 1200 × 900 px, miniatura de 640 × 480 px e icono cuadrado de 256 × 256 px; el publicador generará variantes optimizadas y comprobará peso, recorte, licencia y contraste con el texto superpuesto. El texto esencial no debe ir incrustado en la imagen. Si la imagen contiene texto significativo, se entrega una variante por idioma. Cada arte informativo tiene alternativa textual traducida; el puramente decorativo se oculta a tecnologías de asistencia. + +El cambio de estado se narra en tres momentos: **antes**, la acción muestra que se ha enviado o está pendiente; **durante**, la animación representa únicamente un estado confirmado; **después**, resultado y siguientes acciones son legibles sin depender del movimiento. El plan de presentación del motor coordina efectos y marcadores, pero no altera reglas ni orden de confirmación. Las microtransiciones de interfaz se mantienen breves (aprox. 120–240 ms); las animaciones de juego requieren límite, cancelación o salto y una alternativa con movimiento reducido. El sonido parte desactivado hasta una acción explícita y ofrece volumen y silencio accesibles. + +## Seguridad y lenguaje + +La protección de menores forma parte de todas las vistas. Una sala protegida muestra claramente que la comunicación está desactivada; no se dibuja un chat bloqueado como invitación a verificar un teléfono. Bloquear, reportar, salir y pedir ayuda conservan acceso visible. Las señales de seguridad usan palabras comprensibles y no exponen a otras personas la causa privada de una restricción. No se usan rachas, cuentas atrás persuasivas ni recompensas por permanencia. + +La interfaz escribe acciones concretas, en tono breve y cercano. El mismo verbo nombra botón, estado y confirmación. Los errores dicen qué sucedió y qué puede hacer la persona. Todo texto, alternativa, etiqueta, notificación y regla visible sigue la [política de idiomas](localization.md); se deja espacio para expansión y escrituras futuras de derecha a izquierda. + +## Criterio de aceptación + +Una vista se considera diseñada cuando dispone de estados de carga, vacío, error, permiso y reconexión pertinentes; funciona con teclado, lector de pantalla, zoom y movimiento reducido; se revisa en 320, 375, 768 y 1440 CSS px; y se prueba con textos traducidos más largos. El objetivo es [WCAG 2.2 AA](https://www.w3.org/TR/WCAG22/). Para imágenes se siguen las [pautas WAI sobre alternativas](https://www.w3.org/WAI/tutorials/images/). La tipografía [Manrope se distribuye con licencia OFL-1.1](https://fontsource.org/fonts/manrope/about). diff --git a/frontend/index.html b/frontend/index.html index 00decb1..80283c4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - + =24.0.0" } }, + "node_modules/@fontsource-variable/manrope": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.3.0.tgz", + "integrity": "sha512-6D5dgokHsWDDMtmXHznKa0hK229NN+1a4BLPmUCLqcO1Pw5EEhWY5RFt0AcXnVRAljFFPfRtLkJePQj6LSsV6g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6b11223..f5ed36f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "preview": "vite preview --host 127.0.0.1 --port 4175 --strictPort" }, "dependencies": { + "@fontsource-variable/manrope": "5.3.0", "svelte": "5.57.1" }, "devDependencies": { diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index d62b6b3..8c5dafe 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,8 +1,10 @@