# Marketplace de SouthGames — operación completa para agentes > Documento autocontenido para asistentes de código. Cubre el ciclo entero de > un juego HTML5 en el marketplace: registro de desarrollador, creación del > juego, empaquetado, envío de versión, revisión, publicación, instalación por > las organizaciones y actualización. Para CONSTRUIR el juego (protocolo del > bridge, esqueleto, harness, reglas duras), el documento hermano es > https://southgames.ai/llms-game-sdk.txt > > Fuente de verdad: el código de la plataforma (router del marketplace, > validador de versiones, handler del embed, enriquecimiento de campañas). > Portal: https://southgames.ai — sección **Developer → Mis juegos** > (`/developer/games`). > > Última revisión: 2026-08-28. --------------------------------------------------------------------------- ## 0. Actores y modelo de datos --------------------------------------------------------------------------- | Actor | Qué hace | | ----- | -------- | | **Desarrollador** | Crea el juego, sube versiones, ve estadísticas y reseñas. Un perfil por usuario | | **Revisor de SouthGames** (admin) | Aprueba o rechaza cada versión; puede suspender o deprecar un juego | | **Organización** (cliente B2B) | Instala el juego, lo configura y lo asocia a una campaña | | **Jugador** | Cliente final de la organización; juega desde la app de esa organización | Cuatro documentos importan, y confundirlos es la causa del error operativo más caro (§6): marketplace_games/{gameId} · ficha global del juego: nombre, slug, categoría, permisos, configSchema, pricing, visibilidad · status: draft | in_review | published | rejected | suspended | deprecated ← ESTADO DE VIDA DEL JUEGO: solo `published` se sirve a jugadores. Hoy `in_review` y `rejected` solo se ESCRIBEN en juegos que nunca se publicaron. Ojo: hasta agosto de 2026 el código los escribía también sobre juegos publicados (ese era el bug de §4.3), así que en Firestore pueden quedar fichas `in_review`/`rejected` CON `currentVersionId`: el panel de admin las sigue encolando por `status` y aprobarlas o rechazarlas las devuelve a `published` · currentVersion / currentVersionId ← LA VERSIÓN PUBLICADA · pendingVersionId ← LA VERSIÓN ENVIADA QUE ESPERA REVISIÓN (o null). Es la cola de revisión, y es independiente de `status`: un juego publicado con una actualización esperando sigue `published` y sigue sirviéndose marketplace_games/{gameId}/versions/{versionId} · un ZIP sellado: version (semver), storagePath, bundleHash, changelog · status: in_review | approved | rejected organizations/{orgId}/installedGames/{gameId} · versionId ← LA VERSIÓN INSTALADA POR ESA ORGANIZACIÓN · config (defaults de la org), status: active | disabled organizations/{orgId}/campaigns/{campaignId} · gameType: "marketplace:{gameId}", config (overrides de la campaña) **La versión publicada y la versión instalada son dos punteros distintos.** Publicar mueve el primero; entregar mueve el segundo. Ver §6. ### 0.1 Nombre de cada operación (índice para no adivinar) Todo el ciclo se opera desde el portal. Debajo de cada botón hay una **operación tRPC del router `marketplace`**, y este documento la nombra siempre con su prefijo: `marketplace.`. Sirven para saber exactamente qué paso estás describiendo, para leer un error en la consola del navegador y para hablar con soporte. **No hay API REST pública del marketplace**: estas operaciones son la API interna del portal y solo responden con la sesión del usuario que tiene el rol correspondiente. La vía normal —y la única documentada— es la interfaz. | Paso | Operación | Quién puede | Dónde en el portal | | ---- | --------- | ----------- | ------------------ | | Alta de desarrollador (§1) | `marketplace.registerDeveloper` | cualquier usuario con sesión | Developer → Mis juegos | | Ver el perfil propio | `marketplace.getDeveloperProfile` | el desarrollador | Developer → Mis juegos | | Crear el juego (§2) | `marketplace.createGame` | desarrollador registrado | Mis juegos → nuevo juego | | Editar la ficha (permisos, `configSchema`, precio, visibilidad) | `marketplace.updateGame` | dueño del juego | Detalle del juego | | Subir ícono y capturas | `marketplace.updateGameImages` | dueño del juego | Detalle del juego | | Listar mis juegos | `marketplace.myGames` | el desarrollador (devuelve solo los suyos) | Mis juegos | | Listar las versiones de un juego, con su estado de revisión | `marketplace.getGameVersions` | cualquier usuario con sesión; la UI solo lo muestra al dueño | Detalle del juego | | Enviar una versión (§4) | `marketplace.submitVersion` | dueño del juego | Detalle del juego → subir versión | | Aprobar o rechazar (§5) | `marketplace.reviewVersion` | **admin de SouthGames** | Admin → Marketplace → Pendientes | | Cola de revisión pendiente | `marketplace.listPendingReviews` | admin de SouthGames | Admin → Marketplace → Pendientes | | Suspender / deprecar / re-publicar una ficha | `marketplace.adminUpdateGameStatus` | admin de SouthGames | Admin → Marketplace → Publicados | | Borrar un juego (solo sin publicar) | `marketplace.deleteGame` | dueño del juego | Detalle del juego | | Catálogo que ve la organización | `marketplace.browse` | miembro de la org | Marketplace | | Ficha pública de un juego (con sus versiones aprobadas) | `marketplace.getGame` | cualquier usuario con sesión | Marketplace → ficha del juego | | Instalar (§6.1) | `marketplace.install` | rol con escritura en la org | Marketplace → ficha del juego | | Listar lo instalado | `marketplace.listInstalled` | miembro de la org | Marketplace → Juegos instalados | | Editar la config instalada | `marketplace.updateInstalledConfig` | rol con escritura en la org | Juegos instalados | | **Actualizar la versión instalada (§6.2)** | `marketplace.updateInstalledVersion` | rol con escritura en la org | Juegos instalados → "Actualizar" | | Alternar el flag `versionPinned` (inerte, §6.2) | `marketplace.toggleVersionPin` | rol con escritura en la org | Juegos instalados | | Desinstalar (§8) | `marketplace.uninstall` | rol con escritura en la org | Juegos instalados | | Generar un código de canje (§8) | `marketplace.generateRedeemCode` | dueño del juego, solo si es privado | Detalle del juego | | Listar los códigos generados | `marketplace.listRedeemCodes` | dueño del juego | Detalle del juego | | Canjear un código (§8) | `marketplace.redeemCode` | rol con escritura en la org | Marketplace (campo de canje) o la ficha del juego | | Comprar un juego de pago | `marketplace.createGameCheckout`, `marketplace.getGamePurchase` | rol con escritura en la org | Marketplace → ficha del juego | | Publicar o editar una reseña (§8) | `marketplace.submitReview`, `marketplace.deleteReview` | rol con escritura en la org | Marketplace → ficha del juego | | Leer reseñas | `marketplace.getReviews` | usuario con sesión | Ficha del juego | | Stripe Connect del desarrollador (§1) | `marketplace.createConnectAccount`, `marketplace.getConnectOnboardingUrl`, `marketplace.getConnectDashboardUrl`, `marketplace.refreshConnectStatus` | el desarrollador | Developer → cobros | | Estadísticas y ganancias | `marketplace.getDeveloperStats`, `marketplace.getDeveloperEarnings` | el desarrollador | Developer → Mis juegos | Tres roles distintos aparecen en esa tabla y conviene no confundirlos: el **desarrollador** (dueño del juego, opera sobre `marketplace_games/{gameId}`), la **organización** (opera sobre sus `installedGames`, y necesita un rol con permisos de escritura: un `viewer` no puede instalar ni actualizar) y el **admin de SouthGames** (único que aprueba versiones y cambia el estado de una ficha). Ninguno puede hacer el trabajo del otro: en particular, **el desarrollador no puede actualizar la versión instalada de ninguna organización** (§6.2). --------------------------------------------------------------------------- ## 1. Paso 1 — Registrarse como desarrollador --------------------------------------------------------------------------- Sin perfil de desarrollador **activo** no se puede crear ningún juego (`FORBIDDEN: Debes registrarte como desarrollador primero`). Procedimiento: entrar al portal con una cuenta de SouthGames, ir a **Developer → Mis juegos** (`/developer/games`) y completar el alta. La operación que hay detrás del formulario es **`marketplace.registerDeveloper`**. Campos y validación exacta: | Campo | Regla | | ----- | ----- | | `displayName` | obligatorio, 2 a 80 caracteres | | `company` | opcional, hasta 100 | | `email` | obligatorio, formato email | | `website` | opcional; si se envía debe ser una URL válida (o cadena vacía) | | `bio` | opcional, hasta 500 | El perfil se crea con `status: "active"` de inmediato (no hay cola de aprobación) y con `stripeConnectStatus: "not_connected"`. Un segundo intento de registro responde `CONFLICT: Ya tienes un perfil de desarrollador`. **Cobros (solo si el juego no es gratis).** Para vender hay que conectar Stripe Connect desde el mismo panel: crear la cuenta (tipo *express*, `marketplace.createConnectAccount`), completar el onboarding (`marketplace.getConnectOnboardingUrl`) y refrescar el estado (`marketplace.refreshConnectStatus`) hasta que quede `active`. Hasta entonces el checkout de compra falla con `PRECONDITION_FAILED: El desarrollador no tiene pagos configurados`. La plataforma retiene un **30%** de comisión; el resto se transfiere a la cuenta conectada. --------------------------------------------------------------------------- ## 2. Paso 2 — Crear el juego --------------------------------------------------------------------------- En **Mis juegos → nuevo juego** (operación **`marketplace.createGame`**). El juego nace en `status: "draft"`, sin versiones, invisible en el marketplace. El **`gameId`** lo asigna la plataforma al crear el juego (no lo eliges tú; el `slug` sí). Es el identificador que aparece en la URL del detalle `/developer/games/{gameId}`, el que se usa en `submitVersion`, en la ruta del bundle y en el `gameType: "marketplace:{gameId}"` de las campañas. Campos y validación exacta: | Campo | Regla | Default | | ----- | ----- | ------- | | `displayName` | 3 a 80 caracteres | — | | `slug` | 3 a 60, solo `[a-z0-9-]`, **único en toda la plataforma** (`CONFLICT: Este slug ya está en uso`) | — | | `shortDescription` | 10 a 200 caracteres | — | | `description` | 20 a 5000 caracteres | — | | `category` | `arcade` \| `puzzle` \| `trivia` \| `luck` \| `strategy` \| `sports` \| `other` | — | | `tags` | hasta 10 strings de ≤30 caracteres | `[]` | | `permissions` | subconjunto de `scoring`, `config`, `user.displayName`, `analytics`, `rewards`, `timer`, `audio` | `["scoring", "config"]` | | `supportedLocales` | al menos 1, strings de ≤5 caracteres | `["es"]` | | `configSchema` | objeto JSON o `null` (§3) | `null` | | `pricing` | `{ model: "free" \| "one_time" \| "subscription", priceUsd: number\|null }` | `{ model: "free", priceUsd: null }` | | `visibility` | `public` \| `private` | `public` | Notas operativas: - **Los permisos viven en la ficha del juego, no en el ZIP.** El host web los aplica en runtime: un método sin su permiso declarado responde `Permiso 'x' no concedido`. Cambiarlos es editar el juego (**`marketplace.updateGame`**), no subir versión. - **`private`** = el juego no aparece en el catálogo público; solo lo instala una organización que canjee un **código de canje** de un solo uso (`SG-XXXXXXXX`) que genera el desarrollador con **`marketplace.generateRedeemCode`** (§8). Los códigos solo existen para juegos privados: pedirlos para uno público responde `BAD_REQUEST: Los códigos de canje solo están disponibles para juegos privados`. - El ícono y las capturas se suben aparte (**`marketplace.updateGameImages`**); no son obligatorios para publicar pero sí para que la ficha se vea bien. - Un juego **publicado no se puede borrar** (**`marketplace.deleteGame`** → `PRECONDITION_FAILED`): primero hay que deprecarlo (**`marketplace.adminUpdateGameStatus`**, acción de admin). --------------------------------------------------------------------------- ## 3. El `configSchema`: el formulario que verá la organización --------------------------------------------------------------------------- Es un objeto JSON plano. Cada clave es un campo; el portal genera con él el formulario que la organización completa al configurar el juego y al asociarlo a una campaña. Ese objeto resultante es exactamente lo que el juego recibe en `getConfig()`. Tipos disponibles: | `type` | Control | Atributos propios | Valor que llega a `getConfig` | | ------ | ------- | ----------------- | ----------------------------- | | `text` | campo de texto | — | string | | `number` | campo numérico | `min`, `max` | number | | `boolean` | switch | — | boolean | | `select` | desplegable | `options` (array de strings) | string (la opción elegida) | Atributos comunes: `label` (si falta se muestra la clave), `default`, `description`. Un `type` desconocido u omitido se dibuja como `text`. Ejemplo completo: { "titulo": { "type": "text", "label": "Título", "default": "Mi juego" }, "meta": { "type": "number", "label": "Toques para ganar", "min": 5, "max": 100, "default": 20, "description": "Cuántos toques necesita el cliente para ganar" }, "sonido": { "type": "boolean", "label": "Sonido", "default": true }, "dificultad": { "type": "select", "label": "Dificultad", "options": ["facil", "normal", "dificil"], "default": "normal" } } Lo que **no** existe, y cómo diseñar alrededor: - **No hay `required`.** Ningún campo es obligatorio para la organización: dale `default` a todo y aplica tus propios defaults al leer. - **El servidor NO valida la config contra el schema.** El formulario del portal es la única barrera y en la vista de juegos instalados la organización edita la config como **JSON libre**. El juego debe tolerar valores ausentes, de otro tipo o fuera de rango: normaliza y acota siempre. - **No hay listas de objetos ni campos anidados.** Modela con campos planos, o pide el dato como `text` y parséalo con validación defensiva. - Cambiar el `configSchema` no migra las configs ya guardadas: las claves viejas siguen llegando y las nuevas llegan ausentes. Ignora lo que no conozcas y aplica defaults a lo que falte. --------------------------------------------------------------------------- ## 4. Paso 3 — Empaquetar y enviar la versión --------------------------------------------------------------------------- ### 4.1 El ZIP cd mi-juego zip -r ../mi-juego.zip . -x "harness*.html" ".*" "__MACOSX/*" "*/.DS_Store" unzip -l ../mi-juego.zip # index.html debe salir SIN prefijo de carpeta Se comprime **el contenido** de la carpeta, no la carpeta. El `index.html` debería quedar en la raíz (se acepta dentro de una subcarpeta, pero entonces todas las rutas de assets se resuelven relativas a ella). ### 4.2 La subida y `submitVersion` Desde el detalle del juego en el portal: número de versión, changelog y archivo. El portal, en este orden: 1. Valida en el navegador que sea `.zip` y **≤ 50 MB** (este límite es solo del cliente; el servidor no lo revisa). 2. Calcula el SHA-256 del archivo localmente. 3. Sube el ZIP a Storage con la ruta `marketplace-bundles/{gameId}-v{version}-{timestampMs}.zip`. 4. Llama `marketplace.submitVersion` con este shape exacto: { gameId: "abc123", // string, obligatorio version: "1.0.0", // /^\d+\.\d+\.\d+$/ changelog: "Primera versión pública.", // 5..2000 caracteres minSdkVersion: "1.0", // opcional, default "1.0" storagePath: "marketplace-bundles/abc123-v1.0.0-1724700000000.zip", fileSizeBytes: 184320, // ≥ 1 bundleHash: "sha256:9f86d0…" // opcional; se verifica } Lo que el servidor valida, en orden, antes de crear nada: | Comprobación | Error si falla | | ------------ | -------------- | | El juego existe | `NOT_FOUND: Juego no encontrado` | | Eres el `developerId` del juego | `FORBIDDEN: No eres el desarrollador de este juego` | | Esa cadena de versión no existe ya | `CONFLICT: La versión X.Y.Z ya existe` | | Semver estricto `X.Y.Z` | `Debe ser semver: X.Y.Z` | | Changelog de 5 a 2000 caracteres | error de validación | | `storagePath` coincide con `^marketplace-bundles/{gameId}-v{version}-\d+\.zip$` | `BAD_REQUEST: storagePath inválido: no corresponde a un bundle de este juego y versión` | | El objeto existe en Storage | `BAD_REQUEST: El archivo del bundle no existe en Storage` | | El SHA-256 declarado coincide con el que calcula el servidor | `BAD_REQUEST: El hash del archivo no coincide… reintenta la subida` | | El archivo parsea como ZIP | `BAD_REQUEST: El archivo no es un ZIP válido` | | El ZIP contiene `index.html` (raíz o `*/index.html`) | `BAD_REQUEST: El ZIP no contiene index.html` | Si todo pasa, el servidor: - crea la versión con `status: "in_review"`, `bundleHash: "sha256:"` calculado **por él** (el hash del cliente solo sirve para detectar subidas corruptas), `fileSizeBytes` real y la generación del objeto de Storage; - apunta `pendingVersionId` de la ficha a esa versión: eso, y solo eso, la mete en la cola de revisión; - toca `status` de la ficha **solo si el juego nunca se publicó** (no tiene `currentVersionId`): ahí pasa a `in_review`. Si el juego ya está publicado, `status` no se toca — lee §4.3. Lo que el servidor **no** valida: tamaño, contenido del HTML/JS, permisos, `configSchema`, `minSdkVersion`, ni que el juego funcione. Eso es la revisión humana. ### 4.3 Enviar una versión NO baja el juego que ya estaba publicado Esto fue una trampa real hasta agosto de 2026 y está arreglado; si encuentras documentación o notas viejas que digan lo contrario, están desactualizadas. **La garantía, en una línea:** el `status` de la ficha es el estado de VIDA del juego, y la cola de revisión vive aparte, en `pendingVersionId`. Enviar una versión nueva a un juego publicado escribe `pendingVersionId` y **no toca `status`**. Consecuencias, todas verificadas con tests (`src/server/trpc/routers/__tests__/marketplace-review-lifecycle.test.ts`): - El juego **sigue cargando** para todas las organizaciones que lo tienen instalado mientras la versión nueva espera revisión. Siguen sirviendo la versión aprobada anterior, que es exactamente lo que tienen instalado. - Sigue en el catálogo (`marketplace.browse`) y se puede instalar (`marketplace.install`), porque la ficha sigue `published`; la instalación toma la versión vigente, no la pendiente. - **Un rechazo tampoco lo baja**: la versión queda `rejected`, la ficha se limpia de la cola y su `status` no cambia. `rejected` en la ficha solo se escribe en juegos que nunca llegaron a publicarse. - La ficha pasa a `in_review` **solo la primera vez**, cuando el juego aún no tiene ninguna versión aprobada y nadie lo bajó. Un juego `suspended` o `deprecated` conserva su estado aunque el developer envíe versiones: la decisión del admin no se borra por la puerta de atrás. - **Solo se revisa la versión que la ficha marca como pendiente.** Si el developer envía otra encima, la anterior queda `superseded` y aprobarla desde una pestaña vieja falla con CONFLICT en vez de hacer rollback del juego. Tampoco se puede revisar dos veces la misma versión. Lo que **sigue sin existir**: despliegue por etapas, canal beta, canary o versión de prueba por organización. Una ficha tiene un solo estado global y un solo `currentVersionId`. El cambio ocurre de golpe **al aprobar**, no al enviar: en cuanto el revisor aprueba, `currentVersionId` avanza y las organizaciones que no tengan la versión clavada (§6) empiezan a servir la versión nueva. Ese es el momento riesgoso ahora, no el envío. **¿Cuánto dura la revisión? Sigue sin haber SLA.** No hay plazo codificado, ni aprobación automática, ni temporizador: es **una persona de SouthGames** mirando la cola `marketplace.listPendingReviews`, que lista las fichas con `pendingVersionId` **ordenadas por `updatedAt` ascendente** (primero la que lleva más tiempo esperando). La diferencia con antes es que la espera ya no cuesta disponibilidad: mientras no haya respuesta, el juego sigue vivo con la versión anterior. Cómo operar con esto: - Enviar ya no es caro. Prueba igual la versión en preview antes de enviarla (§7): lo que revisas ahí es lo que el revisor va a aprobar. - Planifica el momento de la **aprobación**, no el del envío, si el juego está vivo en campañas activas. Es cuando cambia lo que ven los jugadores. - Si un juego ya publicado aparece `suspended` o `deprecated`, no es efecto de una revisión: lo bajó un admin a mano (§5). **Y si lo que hay que deshacer es una versión ya aprobada** (se publicó, se entregó y está rota), el camino de vuelta existe pero es de la organización, no tuyo: `marketplace.uninstall` seguido de `marketplace.install` con el `versionId` de una versión aprobada anterior — `install` acepta ese campo y lo respeta. Cuesta caro (desinstalar **pausa todas las campañas activas** que usan el juego y la config instalada vuelve a `{}` si no se reenvía), así que es un plan de emergencia, no una rutina. Nota que `marketplace.updateInstalledVersion` **no** sirve para esto: solo copia la `currentVersionId` del momento, es decir, solo avanza. --------------------------------------------------------------------------- ## 5. Paso 4 — Revisión y publicación --------------------------------------------------------------------------- Un revisor de SouthGames abre la versión y decide. La operación es **`marketplace.reviewVersion`** y solo la puede llamar un admin de la plataforma; el desarrollador no tiene forma de aprobarse a sí mismo. La cola que ve el revisor es **`marketplace.listPendingReviews`**: todas las fichas con `pendingVersionId` **ordenadas por `updatedAt` ascendente**, es decir, se atiende primero a la que lleva más tiempo esperando. La cola mezcla los dos casos y la interfaz los distingue: primeras versiones (juego sin `currentVersionId`) y actualizaciones de juegos que ya tienen versión publicada. Por compatibilidad, la cola incluye además las fichas que dejó el código viejo —`status: "in_review"` sin `pendingVersionId`—; resolverlas las normaliza (ver §5, "Fichas heredadas"). **Solo se revisa la versión pendiente.** `reviewVersion` rechaza con `CONFLICT` cualquier versión que no sea la que la ficha marca como pendiente, o que ya no esté `in_review`. Sin esa guarda, aprobar desde una pestaña vieja una versión que el developer ya reemplazó reescribía `currentVersionId` hacia atrás —rollback en producción para todas las orgs instaladas— y rechazar la versión vigente dejaba la ficha `published` apuntando a una versión que el embed se niega a servir (404). Cuando el developer envía una versión encima de otra que nadie revisó, la anterior queda `superseded`. **Aprobar** (`marketplace.reviewVersion` con `action: "approve"`): 1. Re-verifica la integridad: vuelve a hashear el ZIP en Storage y compara con el hash sellado al subir. Si el objeto cambió → `CONFLICT: El bundle en Storage no coincide con el hash sellado al subirlo — no se puede aprobar`. 2. Genera una URL firmada de larga duración para el bundle (`cdnUrl`). 3. Marca la versión `approved` y sella su `bundleHash`. 4. Actualiza la ficha: `currentVersion` y `currentVersionId` = esta versión, `publishedAt` = ahora, y limpia `pendingVersionId` (sale de la cola). Esa escritura va en una transacción que RELEE la ficha: si el developer envió otra versión mientras se hasheaba el bundle (son segundos), la cola queda apuntando a la nueva en vez de perderla. 5. Pone la ficha en `status: "published"` — **salvo que un admin la haya bajado**: si está `suspended` o `deprecated`, aprobar NO la resucita. Esas son decisiones de la plataforma sobre el juego, no juicios sobre el bundle, y solo se deshacen a mano con `marketplace.adminUpdateGameStatus`. La versión sí queda aprobada y sellada como la actual, así que al republicar el juego arranca directamente en ella. 6. Incrementa el contador `gamesPublished` del desarrollador. **Rechazar** (`marketplace.reviewVersion` con `action: "reject"`): la versión queda `rejected` con las notas del revisor (visibles en el detalle del juego) y la ficha sale de la cola (`pendingVersionId` → null). El `status` de la ficha **solo cambia a `rejected` si el juego nunca se publicó**; si ya había una versión aprobada sirviéndose, el juego sigue publicado y sigue cargando. Se corrige y se sube una versión NUEVA — una versión rechazada no se puede reenviar ni reutilizar su número. Estados posibles de la ficha: `draft`, `in_review` (esperando su PRIMERA versión), `published`, `rejected` (le rechazaron la primera versión), `suspended` (admin la bajó), `deprecated` (admin la retiró). Solo `published` sirve el juego a jugadores. **Fichas heredadas del bug viejo.** Hasta agosto de 2026 enviar una versión ponía la ficha entera en `in_review` (y rechazarla, en `rejected`), incluso con un juego publicado. Las fichas que quedaron así siguen apareciendo en la cola —`listPendingReviews` las incluye por `status`— y al aprobar o rechazar su versión pendiente vuelven a `published`, que es el estado que el código viejo pisó. No hace falta ningún backfill manual: se normalizan solas al resolverse. Una ficha `rejected` sin versión pendiente no tiene nada que revisar; esa se levanta con **`marketplace.adminUpdateGameStatus`** → `published`. **Inmutabilidad.** Una versión aprobada está sellada por hash: sus bytes no pueden cambiar. Cada vez que el juego se sirve, el servidor recalcula el hash del ZIP y se niega a servirlo si no coincide. **Todo arreglo, por mínimo que sea, es una versión nueva con un número nuevo.** --------------------------------------------------------------------------- ## 6. Paso 5 — Instalación… y la trampa que cuesta un día --------------------------------------------------------------------------- ### 6.1 Cómo instala una organización Desde **Marketplace** en el portal de la organización: el catálogo lo lista **`marketplace.browse`** (solo juegos `published` y visibles para esa org) y el botón *Instalar* llama **`marketplace.install`**. Requisitos que el servidor comprueba: - El juego debe estar `published` (`NOT_FOUND: Juego no encontrado o no disponible`). - No puede estar ya instalado y activo (`CONFLICT: Este juego ya está instalado`). - Si es **privado** o **de pago**, la organización necesita una compra o canje activo (`PRECONDITION_FAILED: Necesitas un código de canje para instalar este juego` / `Debes comprar este juego antes de instalarlo`). Al instalar se crea `organizations/{orgId}/installedGames/{gameId}` con `versionId` = la `currentVersionId` **de ese momento**, `config: {}` y `status: "active"`. La instalación **no pide configuración**: la organización la completa después (JSON libre en "Juegos instalados", **`marketplace.updateInstalledConfig`**) y/o por campaña (formulario generado desde el `configSchema`). Atajo que existe: si la organización crea una campaña con `gameType: "marketplace:{gameId}"` y el juego no estaba instalado, la plataforma lo instala sola con la `currentVersionId` del momento. ### 6.2 LA TRAMPA: publicar NO entrega **Publicar mueve `marketplace_games/{gameId}.currentVersionId`. Servir usa `organizations/{orgId}/installedGames/{gameId}.versionId`. Son punteros distintos y nada los sincroniza.** La URL que carga el jugador se firma con la versión INSTALADA: /api/v1/games/embed?gameId={gameId}&version={versionIdInstalado}&token={hmac} Resultado: aprobar la versión 1.1.0 no le cambia nada a una organización que instaló la 1.0.0. Seguirá sirviendo 1.0.0 para siempre —incluida la de tu propia organización de pruebas, que es donde suele empezar el "pero si ya lo arreglé"—. Cómo se entrega de verdad: **cada organización debe actualizar su versión instalada**, con `marketplace.updateInstalledVersion({ gameId })`, que copia `currentVersionId` sobre `installedGames.versionId`. En el portal es el botón **"Actualizar a vX.Y.Z"** que aparece en la tarjeta del juego en *Marketplace → Juegos instalados* (y en la ficha del juego) cuando `installed.versionId !== game.currentVersionId`. Requiere rol con permisos de escritura en la organización; un `viewer` no puede. **¿Se entera la organización de que hay versión nueva? Solo si entra a mirar.** Verificado en el código: al aprobarse una versión **no se dispara ninguna notificación** — ni correo, ni push, ni webhook, ni aviso en la campana del dashboard, ni contador en el menú. `marketplace.reviewVersion` actualiza los documentos y nada más. El **único** indicador del producto es ese botón "Actualizar a vX.Y.Z", que se calcula en el cliente al renderizar la página de *Juegos instalados* (y la ficha del juego): existe únicamente para quien abre esa pantalla. Una organización que no la visite puede quedarse meses en una versión vieja sin ninguna señal. Consecuencia que hay que asumir al planificar: **la entrega de un arreglo crítico depende de un canal informal** (tu aviso por fuera de la plataforma) y de que alguien con permisos de escritura entre y pulse el botón. No hay forma de forzarlo ni de medirlo desde tu lado. Como desarrollador no puedes hacerlo por ellas. Lo que sí puedes: - Poner en el changelog qué cambia y por qué conviene actualizar: es el único texto tuyo que la organización lee justo al lado del botón. - Avisar tú, por el canal que tengas con cada organización, cuando publicas algo importante — y decirles explícitamente dónde está el botón (*Marketplace → Juegos instalados*), porque no es obvio. - Pedirles que revisen esa pantalla después de cada aviso tuyo, y confirmar con ellas que la tarjeta ya no muestra "Actualizar". - No romper compatibilidad con configs viejas: hasta que actualicen, conviven dos versiones de tu juego con la misma config. Como no controlas cuándo actualizan, **diseña cada versión para que la anterior siga siendo aceptable indefinidamente**. *Detalle del código, por si lo ves en la UI*: existe un flag `versionPinned` en el documento instalado y un botón para alternarlo, pero **ningún camino de servicio lo consulta**. En la práctica toda instalación está "pineada": la versión solo cambia cuando alguien pulsa Actualizar. ### 6.3 Caché del embed Una vez resuelta la versión, el HTML construido se sirve con `Cache-Control: public, max-age=3600, s-maxage=3600` y un `ETag` derivado del hash del ZIP. Dispositivo y CDN revalidan cada hora. - El contenido de una versión es inmutable, así que el jugador descarga el juego **una vez por versión**. - La autorización NO se cachea: si un admin suspende el juego o rechaza la versión, el corte llega en la siguiente revalidación (a lo sumo ~2 h encadenando dispositivo y CDN; un deploy de hosting purga el CDN). - La vista previa nunca se cachea. Si actualizaste la versión instalada y el jugador sigue viendo la anterior, **primero** confirma el puntero (§6.2); recién después sospecha de la caché. ### 6.4 Qué ve el jugador 1. La app de la organización pide sus campañas al SDK. Una campaña de marketplace trae: - `embedUrl` firmado con HMAC, apuntando a la versión instalada; - `gameConfig` = `{...config instalada por la org, ...config de la campaña}` (la campaña pisa a la instalación). 2. La app abre esa URL en un WebView (o el dashboard en un iframe sandboxed) y levanta el bridge `postMessage`. 3. El juego hace su handshake, el jugador juega y el juego reporta el resultado. 4. El host registra la partida contra `POST /api/v1/games/play`, que decide el premio, otorga XP y puntos, alimenta el ranking con el `score` reportado y devuelve el código promocional si corresponde. 5. El juego muestra el resultado y cierra. Cosas que la organización controla y el desarrollador no: probabilidad de premio, límites de jugadas y de premios, tipo de recompensa (código, puntos o `none`, esta última compitiendo solo por ranking), textos del servidor y bans de jugadores. --------------------------------------------------------------------------- ## 7. Cómo se prueba antes de publicar --------------------------------------------------------------------------- Tres niveles, del más barato al más caro: ### 7.1 Harness local Un HTML que simula al host completo (requests, `init`, eventos, permisos, cierre negociado). Es la única forma de probar sin subir nada. Está copiable en https://southgames.ai/llms-game-sdk.txt §5. Sin él, un `index.html` suelto deja morir cada request por timeout a los 30 s. ### 7.2 "Probar juego" en el portal (preview real) En el detalle del juego, el botón *Probar juego* abre el reproductor del dashboard contra el embed real. El modo preview **salta los chequeos de `published`/`approved`**: como desarrollador del juego (o admin) puedes probar **cualquier** versión, incluso una que aún está en revisión o fue rechazada. Un miembro de la organización también puede abrirlo, pero solo sobre una versión `approved` y si el juego no está `suspended`. Es la prueba que hay que hacer antes de enviar una versión, no después. En ese modo: - Se concede el juego completo de permisos (`config`, `user.displayName`, `scoring`, `analytics`). - `submitResult` NO registra nada: siempre responde `{ promoCode: null, rewardType: null, message: "Modo prueba — resultado no registrado" }`. Tu pantalla final tiene que verse bien así. - El token de preview dura 10 minutos y no se cachea nada. ### 7.3 Vista previa de campaña (attract mode) Cuando una organización arma una campaña, el dashboard muestra el juego real jugándose solo, cargándolo con `?demo=1`. Ahí: - solo se conceden los permisos `config` y `user.displayName`; - `getConfig` puede devolver `{}` (la vista previa no pasa configuración); - el juego **no debe** llamar `submitResult` ni `trackEvent`. Un juego que no implementa el modo demo simplemente se queda en su pantalla de inicio: no rompe nada, pero pierde la vitrina. --------------------------------------------------------------------------- ## 8. Actualizaciones, precios y códigos de canje --------------------------------------------------------------------------- **Actualizar el juego (flujo completo):** 1. Arregla, prueba en el harness (§7.1), prueba en preview (§7.2). 2. `marketplace.submitVersion` con semver nuevo (una versión usada jamás se repite) y changelog claro. El juego publicado **sigue sirviendo la versión anterior** mientras espera revisión (§4.3); la revisión no tiene SLA, pero esperarla ya no cuesta disponibilidad. 3. `marketplace.reviewVersion` (admin) → aprobación → `currentVersionId` apunta a la nueva. Es el momento en que cambia lo que ven los jugadores: acuérdalo con SouthGames si el juego está vivo en campañas. 4. **Avisa tú a cada organización**: la aprobación no notifica a nadie (§6.2). 5. **Cada organización pulsa "Actualizar"** (`marketplace.updateInstalledVersion`) para recibirla (§6.2). **Precios.** `free` (instalación directa), `one_time` (pago único por Stripe Checkout, **`marketplace.createGameCheckout`**) o `subscription` (mensual). Los pagos van a la cuenta Stripe Connect del desarrollador menos el 30% de la plataforma. Un juego de pago exige una compra activa (**`marketplace.getGamePurchase`**) antes de instalarse. **Códigos de canje.** Solo para juegos con `visibility: "private"`. El desarrollador los genera de a uno con **`marketplace.generateRedeemCode`** (formato `SG-XXXXXXXX`, 8 caracteres del alfabeto sin ambigüedades `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`; sin `I`, `O`, `0` ni `1`) y los lista con **`marketplace.listRedeemCodes`**. La organización lo canjea con **`marketplace.redeemCode`** y obtiene acceso. Cada código es de un solo uso (queda con `redeemedBy` / `redeemedOrgId`). En un juego privado gratis el canje concede el acceso al instante; en uno de pago el código se consume recién cuando el pago se completa. **Desinstalación (lado organización).** **`marketplace.uninstall`** marca la instalación como `disabled`, **pausa automáticamente todas las campañas activas** que usaban ese juego (las deja en `paused` y devuelve cuántas fueron en `pausedCampaigns`) y descuenta el contador de instalaciones. Es una operación de la organización, no del desarrollador: tú no puedes desinstalar tu juego de nadie. **Reseñas.** **`marketplace.submitReview`**: una por usuario y juego (volver a enviar edita la anterior), puntuación 1-5 y comentario de 5 a 1000 caracteres; el promedio del juego se recalcula en cada envío. Solo se puede reseñar un juego `published`. **`marketplace.deleteReview`** borra la propia y **`marketplace.getReviews`** las lista. --------------------------------------------------------------------------- ## 9. Errores frecuentes: síntoma → causa → arreglo --------------------------------------------------------------------------- | Síntoma | Causa | Arreglo | | ------- | ----- | ------- | | `Debes registrarte como desarrollador primero` | No hay perfil de desarrollador activo | Crear el perfil en Developer → Mis juegos | | `Este slug ya está en uso` | El slug es único global | Elegir otro | | `La versión X.Y.Z ya existe` | Reenvío del mismo número | Subir la siguiente versión semver | | `Debe ser semver: X.Y.Z` | `1.0`, `v1.0.0`, `1.0.0-beta` | Usar exactamente tres números | | `storagePath inválido…` | Se envió una ruta armada a mano | Subir por el portal: la ruta la arma él | | `El hash del archivo no coincide…` | Subida corrupta | Reintentar la subida completa | | `El archivo no es un ZIP válido` | Se comprimió mal o se subió otra cosa | Regenerar el ZIP | | `El ZIP no contiene index.html` | Se comprimió la carpeta en vez del contenido | `cd carpeta && zip -r ../j.zip .`, verificar con `unzip -l` | | El juego publicado deja de cargar (404) para todos | **Ya no lo causa enviar una versión** (§4.3): la ficha está `suspended`, `deprecated` o `rejected`, es decir, la bajó un admin o nunca llegó a publicarse | Mirar el `status` de la ficha y hablar con SouthGames; ninguna acción del desarrollador la devuelve a `published` | | El juego abre bien en el dashboard pero los jugadores ven error | La vista previa se salta el chequeo de estado de la ficha, el embed real no: la ficha no está `published` | Ver la fila anterior — es el estado de la ficha, no un problema del bundle | | Aprobaron la versión nueva y los jugadores ven la vieja | La organización no actualizó su versión instalada | Botón "Actualizar" en Juegos instalados (§6.2) | | La organización nunca se enteró de la versión nueva | No existe notificación al aprobar: el único indicador es el botón en *Juegos instalados* | Avisar por fuera de la plataforma y confirmar que pulsaron (§6.2) | | Hay que volver a una versión anterior ya entregada | `updateInstalledVersion` solo avanza a `currentVersionId` | La org desinstala y reinstala con `versionId` explícito; pausa sus campañas (§4.3) | | Actualizaron y sigue la vieja | Caché del embed (hasta ~2 h) | Esperar la revalidación / recargar | | `Juego del marketplace no instalado o inactivo` al jugar | La campaña apunta a un juego desinstalado o deshabilitado | Reinstalar el juego en esa organización | | `Permiso 'scoring' no concedido` en producción | El permiso no está en la ficha del juego | Editar el juego y declararlo (no requiere versión nueva) | | El juego no aparece en el catálogo | `status` distinto de `published`, o `visibility: "private"` | Revisar el estado; en privado, entregar un código de canje | | `No puedes eliminar un juego publicado` | Borrado bloqueado tras publicar | Pedir a un admin que lo deprecue | | `El desarrollador no tiene pagos configurados` | Stripe Connect sin completar | Terminar el onboarding hasta estado `active` | --------------------------------------------------------------------------- ## 10. Checklist operativo --------------------------------------------------------------------------- Antes de enviar una versión - [ ] El juego pasa el checklist de construcción (llms-game-sdk.txt §8). - [ ] Probado en el harness local, incluidos demo, host mudo y cierre con envío en vuelo. - [ ] Probado en el portal con "Probar juego" (preview de la versión real). - [ ] `index.html` en la raíz del ZIP, harness y basura del sistema excluidos. - [ ] ZIP < 2-3 MB (límite duro del portal: 50 MB). - [ ] Versión semver nueva y changelog de 5 a 2000 caracteres útil para la organización. - [ ] Los permisos declarados en la ficha cubren todos los métodos que el juego usa. - [ ] El `configSchema` tiene `default` en todos los campos y el juego funciona con `{}`. - [ ] Sabes que el juego publicado sigue sirviendo la versión anterior durante la revisión, y que el cambio para los jugadores ocurre al APROBAR (§4.3). - [ ] Si el juego ya está vivo: momento de la aprobación acordado con SouthGames y organizaciones avisadas. Después de la aprobación - [ ] Verificar que `currentVersion` en la ficha es la nueva. - [ ] Probar la versión publicada desde una organización real. - [ ] Avisar tú a las organizaciones instaladas para que pulsen "Actualizar": la plataforma no les notifica nada (§6.2). - [ ] Confirmar en una organización actualizada que el juego carga la versión nueva. --------------------------------------------------------------------------- ## 11. Referencias --------------------------------------------------------------------------- - Construcción del juego (protocolo, esqueleto, harness, reglas duras): https://southgames.ai/llms-game-sdk.txt - Guía equivalente para humanos: https://southgames.ai/docs/guides/marketplace-games - Endpoint de partidas usado por los hosts (incluye `PLAYER_BANNED` y el contrato de `gameResult`): https://southgames.ai/docs/api/play - Índice de recursos para agentes: https://southgames.ai/llms.txt