parent
0982ba0e52
commit
5e41363f76
@ -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<String, MessageArgument>;
|
||||||
|
|
||||||
|
class LocalizedMessage {
|
||||||
|
final String key;
|
||||||
|
final MessageArgs args;
|
||||||
|
const LocalizedMessage({required this.key, required this.args});
|
||||||
|
factory LocalizedMessage.fromJson(Map<String, dynamic> json) => LocalizedMessage(
|
||||||
|
key: _required(json, 'key') as String,
|
||||||
|
args: _object(_required(json, 'args')).map((key, item) => MapEntry(key, _argument(item))),
|
||||||
|
);
|
||||||
|
Map<String, dynamic> 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<String, dynamic> json) => Recovery(
|
||||||
|
action: _required(json, 'action') as String,
|
||||||
|
retryAfterMs: json['retryAfterMs'] == null ? null : json['retryAfterMs'] as int,
|
||||||
|
);
|
||||||
|
Map<String, dynamic> 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<String, dynamic> json) => Violation(
|
||||||
|
pointer: _required(json, 'pointer') as String,
|
||||||
|
code: _required(json, 'code') as String,
|
||||||
|
message: LocalizedMessage.fromJson(_object(_required(json, 'message'))),
|
||||||
|
);
|
||||||
|
Map<String, dynamic> 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<Violation> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) => ErrorMessagePayload(
|
||||||
|
correlation: Correlation.fromJson(_object(_required(json, 'correlation'))),
|
||||||
|
error: ErrorData.fromJson(_object(_required(json, 'error'))),
|
||||||
|
);
|
||||||
|
Map<String, dynamic> 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<String, dynamic> json) => CommandRejection(
|
||||||
|
status: _required(json, 'status') as String,
|
||||||
|
error: ErrorData.fromJson(_object(_required(json, 'error'))),
|
||||||
|
);
|
||||||
|
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'version': version,
|
||||||
|
'title': title,
|
||||||
|
'summary': summary,
|
||||||
|
'locale': locale,
|
||||||
|
'minPlayers': minPlayers,
|
||||||
|
'maxPlayers': maxPlayers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class CatalogPage {
|
||||||
|
final List<GameCard> items;
|
||||||
|
final String? nextCursor;
|
||||||
|
const CatalogPage({required this.items, required this.nextCursor});
|
||||||
|
factory CatalogPage.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) => CatalogResponse(
|
||||||
|
requestId: _required(json, 'requestId') as String,
|
||||||
|
data: CatalogPage.fromJson(_object(_required(json, 'data'))),
|
||||||
|
);
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'requestId': requestId,
|
||||||
|
'data': data.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic _required(Map<String, dynamic> json, String key) {
|
||||||
|
if (!json.containsKey(key)) throw FormatException('Missing field: $key');
|
||||||
|
return json[key];
|
||||||
|
}
|
||||||
|
Map<String, dynamic> _object(dynamic value) => Map<String, dynamic>.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');
|
||||||
|
}
|
||||||
@ -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<String, dynamic>;
|
||||||
|
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<String>().toList()..sort();
|
||||||
|
return {for (final key in keys) key: _canonical(value[key])};
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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<string, Schema>; required?: string[];
|
||||||
|
additionalProperties?: boolean | Schema; items?: Schema;
|
||||||
|
};
|
||||||
|
const root = JSON.parse(await readFile(new URL('schema.json', import.meta.url), 'utf8')) as { $defs: Record<string, Schema> };
|
||||||
|
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<String, ${type(s.additionalProperties, lang)}>` : `Record<string, ${type(s.additionalProperties, lang)}>`;
|
||||||
|
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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json, String key) {\n if (!json.containsKey(key)) throw FormatException('Missing field: $key');\n return json[key];\n}\nMap<String, dynamic> _object(dynamic value) => Map<String, dynamic>.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.`);
|
||||||
|
}
|
||||||
@ -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"`
|
||||||
|
}
|
||||||
@ -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) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<string, MessageArgument>;
|
||||||
|
|
||||||
|
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<Violation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<GameCard>;
|
||||||
|
nextCursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogResponse {
|
||||||
|
requestId: UUID;
|
||||||
|
data: CatalogPage;
|
||||||
|
}
|
||||||
@ -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"]
|
||||||
|
}
|
||||||
@ -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<string, z.ZodType>();
|
||||||
|
|
||||||
|
// 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<string, unknown>;
|
||||||
|
if (name === 'ErrorData') {
|
||||||
|
const recovery = object.recovery as Record<string, unknown>;
|
||||||
|
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<string, unknown>).outcome !== 'rejected' || (object.error as Record<string, unknown>).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<string, unknown>;
|
||||||
|
const correlation = object.correlation as Record<string, unknown>;
|
||||||
|
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)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.`);
|
||||||
@ -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.
|
||||||
@ -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.<surface>.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.
|
||||||
@ -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.
|
||||||
@ -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.
|
||||||
@ -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.
|
||||||
@ -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.<surface>.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.
|
||||||
@ -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.
|
||||||
@ -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).
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// selectLocale negotiates the two translations shipped by the foundation.
|
||||||
|
// Unknown language ranges are ignored and Spanish is the safe fallback.
|
||||||
|
func selectLocale(header string) string {
|
||||||
|
selected := "es"
|
||||||
|
bestWeight := -1.0
|
||||||
|
for index, item := range strings.Split(header, ",") {
|
||||||
|
if index >= 20 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimSpace(item), ";")
|
||||||
|
language := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||||
|
locale := ""
|
||||||
|
switch {
|
||||||
|
case language == "es" || strings.HasPrefix(language, "es-"):
|
||||||
|
locale = "es"
|
||||||
|
case language == "en" || strings.HasPrefix(language, "en-"):
|
||||||
|
locale = "en"
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
weight := 1.0
|
||||||
|
if len(parts) > 1 {
|
||||||
|
if len(parts) != 2 || !strings.HasPrefix(strings.TrimSpace(parts[1]), "q=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseFloat(strings.TrimPrefix(strings.TrimSpace(parts[1]), "q="), 64)
|
||||||
|
if err != nil || parsed < 0 || parsed > 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
weight = parsed
|
||||||
|
}
|
||||||
|
if weight > bestWeight && weight > 0 {
|
||||||
|
bestWeight = weight
|
||||||
|
selected = locale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected
|
||||||
|
}
|
||||||
Loading…
Reference in new issue