Praxsuite

Caso de Uso del SDK de Java en Minecraft

¿Qué vamos a construir?

Un Buscaminas completo corriendo dentro de un servidor de Minecraft, con el mismo backend — tablas, automations, leaderboard — que las versiones de este juego en Roblox, Unity y web. El tablero es una cuadrícula de bloques en el mundo: clic izquierdo revela, clic derecho coloca una bandera. Cuando la partida termina, el resultado se verifica contra reglas que residen en Praxsuite, no en la buena conducta del propio plugin.

Al terminar esta guía vas a tener:

  • Un servidor de Paper configurado, con el plugin cargando y sus credenciales fuera del jar

  • Las tablas, los roles, los endpoints y las automations del juego, construidos desde cero

  • Un jugador con sesión iniciada apenas se conecta, sin ninguna pantalla de login — lo verifica online-mode:true

  • Un rol asignado vía una automation que escala a cualquier plataforma futura, no un checkbox del portal por cada proveedor

  • Un tablero que responde a cada clic al instante, con cero latencia de red por movimiento

  • Un resultado que Praxsuite recalcula por su cuenta y rechaza si algo no cierra

  • Un leaderboard en vivo, dentro del juego, alimentado por el Event Bus

Nivel requerido: completaste la guía Java SDK Implementation en Minecraft. Te sientes cómodo con eventos de Bukkit, el scheduler, y manipulación básica de bloques. No hace falta que hayas hecho ninguna de las otras guías de Buscaminas: esta construye su propio backend. ¿Ya hiciste la de Roblox, Unity o TypeScript? Entonces la Parte 2 ya está hecha en ese workspace. Verifica que los nombres coincidan con los de acá, agrega el endpoint `Validar Resultado` si no lo tienes, y sigue desde el Paso 12. Un solo backend sirve a todas las plataformas a la vez — esa es la gracia del leaderboard compartido.


Cómo funciona

La matriz de minas vive en una tabla llamada Buscaminas Partidas y nunca sale del workspace, salvo una vez — el instante en que se pierde una partida, y solo el trazado de minas, nunca antes. Lo que viaja de un lado a otro es un conjunto mucho más pequeño de decisiones:

Corre en el plugin (Java)

Corre en Praxsuite

Abrir el tablero y dibujarlo como bloques

Reservar la fila de la partida, sus dimensiones y cantidad de minas

Colocación de minas, flood-fill, detección de victoria/derrota, puntaje — en cada clic

Recalcular todo lo anterior a partir del reporte final, y negarse a cerrar la partida si algo no es consistente

Manejar clics con cero ida y vuelta de red

Ser dueño del leaderboard, y publicar allí

Asignar un rol una vez verificado el propio token del jugador

Verificar ese token — nunca confiar en un id crudo que envíe el cliente

Esta es una elección deliberada, no la más simple posible. Un diseño con autoridad del servidor que llama a una automation en cada clic es más fácil de razonar y es igual de seguro — de hecho, es lo que hace hoy la versión de Roblox de este mismo juego. También es la ida y vuelta que se convierte en latencia percibida apenas un jugador hace clic rápidamente por el tablero. La sección "Por qué local, y no una automation por clic", cerca del final de esta guía, desarrolla ese equilibrio completo; la versión corta es que un plugin de Paper no tiene otra entrada tan frecuente como esta, así que vale la pena — es el único lugar de todo el proyecto donde se confía al cliente lógica en vez de solo mostrarle algo.



Requisitos Previos

Repositorio: https://github.com/TesseractSoftwares/Praxsuite-SDK-Java

Requisito

Descripción

La guía Java SDK Implementation en Minecraft, completa

Esta guía se construye directamente sobre el cliente y el patrón assertPlayer de esa guía.

JDK y Maven

El JDK que pida tu build de Paper (25 para 26.x, 17 para 1.20.x) y Maven para compilar el plugin.

Un workspace de Praxsuite

La Parte 2 crea todo desde cero, así que un workspace nuevo sirve.

Acceso al portal

Necesitas poder crear tablas, roles, endpoints, automations y topics del Event Bus, no solo leerlos.

Nada más. El servidor de Minecraft lo levantamos en la Parte 1, y el backend en la Parte 2.


Parte 1 — El servidor de Minecraft

Si ya tienes un servidor de Paper corriendo y tu plugin cargando en él, salta a la Parte 2. Si no, son quince minutos y hay exactamente un ajuste que no es opcional.

Paso 1 — Levantar un servidor de Paper

Crea una carpeta vacía para el servidor — nunca dentro del proyecto del plugin, porque el servidor escribe mundos, logs y caché, y no quieres nada de eso en tu repositorio.

Descarga el jar de Paper desde papermc.io/downloads y déjalo ahí. La versión importa: tiene que coincidir con el api-version que declara tu plugin.yml, y necesita la versión de Java que pida esa build (Paper 26.2 corre sobre Java 25; las 1.20.x, sobre Java 17). La demo de referencia usa paper-26.2-123.jar.

Arranca el servidor una primera vez, desde la carpeta donde dejaste el jar:

java -Xms2G -Xmx2G -jar paper-26.2-123.jar --nogui

Se va a cerrar de inmediato, y eso es lo esperado: generó un archivo eula.txt y espera que aceptes el EULA de Minecraft. Ábrelo y cambia la única línea que tiene:

eula=true

Paso 2 — `online-mode=true`, el ajuste que sostiene toda la identidad

Vuelve a arrancar. Esta vez el servidor genera el mundo y, con él, server.properties. Ábrelo y confirma:

online-mode=true
server-port=25565

online-mode=true viene activado por defecto, así que normalmente no hay nada que cambiar — pero sí algo que no cambiar, y conviene entender por qué antes de encontrarse una guía de internet que sugiera apagarlo.

onlinemode-true.png

Con online-mode=true, Mojang verifica la cuenta de Microsoft de cada jugador antes de que Bukkit te entregue el objeto Player, y player.getUniqueId() es entonces una identidad que el jugador no puede elegir. Eso es exactamente lo que assertPlayer le está afirmando a Praxsuite. Con online-mode=false, cualquiera puede conectarse escribiendo el nombre que quiera, el UUID pasa a derivarse de ese nombre, y tu servidor estaría abriendo sesiones de Praxsuite a nombre de jugadores arbitrarios — incluidos los puntajes que después firman el leaderboard compartido. El resto de esta guía asume que está en true.

¿Y si quiero probar sin cuenta de Minecraft? Entonces el leaderboard de esa prueba no vale, y conviene que apunte a un workspace de pruebas y no al que comparten las demás plataformas. Apagar `online-mode` es una decisión sobre la confianza de la identidad, no sobre la comodidad del entorno de desarrollo.

Arranca el servidor una tercera vez y déjalo llegar hasta Done. Ya tienes las carpetas world/ y, la que importa acá, plugins/. Escribe stop en la consola para bajarlo ordenadamente.

Paso 3 — Instalar el plugin, y el ciclo de recarga

Compila el plugin con mvn clean package y copia el jar sombreado de target/ a la carpeta plugins/ del servidor. Tres cosas que se aprenden a los golpes:

  • Detén el servidor antes de reemplazar el jar. Windows no deja sobrescribir un archivo que la JVM tiene abierto, y el error que da no menciona al servidor por ningún lado.

  • Nunca dejes dos jars del mismo plugin en `plugins/`. Al renombrarse la versión, es fácil que queden …-1.0-SNAPSHOT.jar y …-1.1.jar conviviendo; Paper lo rechaza con Ambiguous plugin name.

  • `/reload` no alcanza para este plugin. Abre una conexión WebSocket contra el Event Bus y mantiene sesiones por jugador; recargarlo en caliente deja conexiones huérfanas. Reinicia el servidor.

Paso 4 — Las credenciales, en `config.yml` y fuera del jar

La server key del Paso 7 no va escrita en el código. Un jar se descompila, y el que la tenga puede abrir una sesión a nombre de cualquier jugador de tu workspace.

El plugin declara sus credenciales en src/main/resources/config.yml, versionado con los tres valores vacíos:

praxsuite:
  workspace-id: ""
  publishable-key: ""
  server-key: ""

Y las lee al arrancar, dejando que Bukkit copie la plantilla la primera vez:

@Override
public void onEnable() {
    saveDefaultConfig();
    FileConfiguration config = getConfig();
    String workspaceId = config.getString("praxsuite.workspace-id", "").strip();
    String serverKey = config.getString("praxsuite.server-key", "").strip();

    if (workspaceId.isEmpty() || serverKey.isEmpty()) {
        getLogger().severe("Faltan credenciales en config.yml — completa plugins/"
                + getName() + "/config.yml y reinicia el servidor.");
        getServer().getPluginManager().disablePlugin(this);
        return;
    }
    // ... construir los clientes de Praxsuite con esos valores
}

El archivo con las claves reales vive solo en plugins/<NombreDelPlugin>/config.yml, en el servidor. Apagar el plugin cuando falta una credencial no es cortesía: sin ellas no hay una sola función del juego que pueda andar, y un NullPointerException a mitad de una partida explica mucho menos que un mensaje en el arranque.


Parte 2 — El backend

Todo lo que sigue se construye una sola vez y lo comparten todas las plataformas: si ya hiciste la guía de Roblox, Unity o TypeScript sobre este mismo workspace, las tablas, los endpoints y las automations ya existen — verifica los nombres y salta al Paso 12, que sí es específico de Minecraft.

Paso 5 — Crear las dos tablas

En DataEngine, crea una tabla llamada `Buscaminas Partidas`. Una fila es una partida.

Columna

Tipo

Qué guarda

Codigo

ShortText

El código de 8 caracteres que identifica la partida. Márcala como columna clave

Jugador

Enduser

El end user dueño de la partida. Es la columna sobre la que el Paso 6 aplica __SELF__

Alias

ShortText

Nombre visible, lo usa el leaderboard

Jugador Externo Id

ShortText

El id del jugador en la plataforma que lo hospeda — acá, el UUID de Mojang

Estado

Status

Exactamente tres estados: En curso, Ganada, Perdida

Filas

Integer

Alto del tablero

Columnas

Integer

Ancho del tablero

Minas

Integer

Cuántas minas tiene

Dificultad

ShortText

facil, medio o dificil

Matriz

Json

El campo minado. Este es el secreto

Revelado

Json

Grilla de 0/1: qué celdas están abiertas

Banderas

Json

Grilla de 0/1: qué celdas tienen bandera

Celdas Reveladas

Integer

Cuenta corriente, para que detectar la victoria sea una comparación

Puntaje

Integer

Puntaje final, se escribe al cerrar

Inicio

DateTime

Apertura de la partida

Fin

DateTime

Cierre

Segundos

Integer

Duración

Motor

ShortText

minecraft, roblox, unity, web

SDK

ShortText

java, lua, csharp, typescript

Los tres estados se escriben como texto desde las automations, así que un error de tipeo acá es una escritura rechazada más adelante.

Ahora crea `Demos Leaderboard`. Una fila es una partida terminada, y la tabla se comparte con otras demos.

Columna

Tipo

Qué guarda

Record

ShortText

Etiqueta legible de la fila

Alias

ShortText

Nombre visible

Jugador Externo Id

ShortText

El mismo id de arriba

Points

Integer

El puntaje

Dificultad

ShortText

Qué preset se jugó

Segundos

Integer

Cuánto tardó

Juego

ShortText

Siempre Buscaminas

Motor

ShortText

minecraft, roblox, unity, web

SDK

ShortText

java, lua, csharp, typescript

Anota los UUID de ambas tablas desde Gateway → Playground: las automations los necesitan.


Paso 6 — El rol del jugador, y qué NO puede tocar

Este paso es el que decide si todo el resto de la guía sirve para algo. Una cuenta creada por assertPlayer nace sin roles, así que sin esto cualquier query del jugador vuelve vacía o con 403 — pero la respuesta fácil (darle acceso a la tabla y seguir) es exactamente la que rompe el juego.

En Settings → API Gateway → Roles, crea un rol llamado `Buscaminas Jugador`. Dale un scope sobre Buscaminas Partidas con:

  • Row filter: __SELF__ sobre la columna Jugador, para que cada jugador alcance únicamente sus propias partidas.

  • Valor por defecto de la columna Jugador: {{claim:sub}} — se aplica a lo que escriba el propio jugador, y es lo que le da a __SELF__ algo contra qué comparar.

  • Acceso de columna, y acá está todo el asunto:

Columna

Lectura

Escritura

Matriz

No

No

Puntaje, Estado, Celdas Reveladas, Fin, Segundos

Sí

No

Minas, Filas, Columnas, Dificultad, Codigo, Alias, Motor, SDK, Inicio

Sí

No

`Matriz` es el modelo de seguridad entero. Si el rol del jugador puede leerla, un cliente modificado pide su propia fila por el Gateway y sabe dónde están todas las minas antes del primer clic — y el resto de esta guía pasa a ser decorativo. Si además puede escribirla, puede reescribir el campo minado para que su resultado inventado valide. Las automations no pasan por estos scopes: corren con la autoridad del workspace, así que quitarle a `Matriz` ambos permisos no les saca nada. Ojo con quién llena `Jugador`. Las partidas las abre `Nueva Partida`, que corre con la autoridad del workspace y no pasa por estos scopes, así que la columna `Jugador` queda vacía salvo que esa automation la escriba explícitamente. Mientras el juego hable solo por endpoints — el caso de Minecraft — eso no cambia nada. Si además quieres que un cliente lea su propia fila directo por el Gateway, agrega `Jugador` al mapeo de esa automation; si no, ese filtro no va a encontrar filas que mostrar.

El mismo criterio para Demos Leaderboard: el rol del jugador puede leer (el juego muestra el top) y no escribe nunca. La única escritura la hace Validar Resultado, después de recalcular el resultado.

Comprobación rápida: cuando termines la Parte 2, entra a Gateway → Playground, elige el rol `Buscaminas Jugador` y pide `Matriz` de una partida. Tiene que fallar. Si devuelve la grilla, el scope quedó abierto.


Paso 7 — El proveedor de identidad `minecraft` y la server key

En Settings → API Gateway → Game Platforms, registra un proveedor (o confirma que exista) con:

  • Slug: minecraft

  • Cómo inician sesión: "Desde dentro de un juego, sin pantalla de login" (ServerAssertion)

  • Verificación mínima: "Asertado por servidor" — el máximo que puede alcanzar un proveedor dentro del juego

  • Roles con los que nace una cuenta nueva: déjalo vacío. El Paso 12 reemplaza el valor por defecto estático por una automation, que es la versión más escalable de la misma idea

minecraft-provider-config.png

Luego crea una server key (sk_live_…) y márcala para la plataforma minecraft en esta misma pantalla. Es la credencial que tu plugin usa para assertPlayer y para las automations del juego, la que cargaste en el config.yml del Paso 4.

¿Por qué no reutilizar el proveedor de Roblox? Porque el gateway verifica que la plataforma marcada en la clave que llama coincida con el proveedor que se está afirmando — una clave marcada `roblox` no puede abrir una sesión `minecraft`, deliberadamente. Eso es lo que evita que una clave filtrada de una plataforma se reutilice contra otra.


Paso 8 — Crear los cuatro endpoints

En Gateway → Endpoints, crea cuatro endpoints, los cuatro en modo Sync — cada uno bloquea hasta que su automation responde, que es lo que espera endpoints().call:

Endpoint

Entrada

Salida

Buscaminas: Nueva Partida

{ alias, jugadorId, dificultad, motor, sdk }

{ codigo, filas, columnas, minas, vista, … }

Buscaminas: Validar Resultado

la partida terminada entera

{ ok, codigo, estado, puntaje, motivos, mensaje }

Buscaminas: Leaderboard

{ limite, motor? }

{ total, porMotor, top[] }

Buscaminas: Asignar Rol Jugador

{ token }

confirmación breve

Déjalos sin enlazar por ahora: cada uno se enlaza a su automation a medida que la construyes. Copia los cuatro UUID — son los que el plugin usa como constantes.


Paso 9 — La Automation `Nueva Partida`

Crea una Automation llamada Buscaminas: Nueva Partida. Son cuatro nodos en línea recta:

Trigger de endpoint  →  Script: generar tablero  →  Insertar fila  →  Response

Nodo 1 - Trigger de endpoint. Apúntalo al endpoint Buscaminas: Nueva Partida. Dale este payload de prueba para poder correr la Automation sin un juego:

{ "alias": "mirko", "jugadorId": "1", "dificultad": "facil", "motor": "roblox", "sdk": "lua" }

Nodo 2 - Script, llamado generar. Una entrada: payload, con origen {{context.request.body}}. Lenguaje JavaScript.

// -- Buscaminas: apertura de la partida ---------------------------------------
// Entrada: payload (object) <- {{context.request.body}}
//
// Este endpoint es agnostico del motor: lo llaman por igual el SDK de Lua
// (Roblox), el de C# (Unity) y el de TypeScript. Por eso el cuerpo trae
// `motor` y `sdk`, que se guardan con la partida y viajan al leaderboard.
//
// Aca NO se siembran las minas. Solo se reserva el tablero: medidas, cuantas
// minas va a tener, y las grillas de revelado y banderas en cero. Las minas se
// siembran en la primera jugada, cuando ya sabemos que celda toco el jugador y
// podemos dejarla libre.
//
// Sembrarlas aca obliga a que la primera jugada sea una apuesta: en dificil hay
// 45 minas en 256 celdas, asi que una de cada seis partidas se termina en el
// primer click sin que el jugador haya podido decidir nada. Eso no es
// dificultad, es una moneda al aire antes de empezar.

const body = payload || {};

const PRESETS = {
  facil:   { filas: 8,  columnas: 8,  minas: 10 },
  medio:   { filas: 12, columnas: 12, minas: 24 },
  dificil: { filas: 16, columnas: 16, minas: 45 }
};

const dificultad = String(body.dificultad || "facil").toLowerCase();
const preset = PRESETS[dificultad] || PRESETS.facil;

const R = Math.max(4, Math.min(20, Number(body.filas)    || preset.filas));
const C = Math.max(4, Math.min(20, Number(body.columnas) || preset.columnas));
const M = Math.max(1, Math.min(R * C - 1, Number(body.minas) || preset.minas));

const revelado = Array.from({ length: R }, () => new Array(C).fill(0));
const banderas = Array.from({ length: R }, () => new Array(C).fill(0));
const vista    = Array.from({ length: R }, () => "?".repeat(C));

const ALFA = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let codigo = "";
for (let i = 0; i < 8; i++) codigo += ALFA[Math.floor(Math.random() * ALFA.length)];

// `Inicio` queda con la hora de apertura para que la columna nunca este vacia,
// pero todavia no es el reloj de la partida: la automation de jugar lo pisa con
// la hora de la primera jugada. Lo que se cronometra es el juego, no el menu.
const inicio = new Date().toISOString();

const alias = String(body.alias || "anonimo");
// Id del jugador en la plataforma que lo hospeda: UserId de Roblox, id de
// Steam, uuid del navegador. Neutro a proposito.
const jugadorId = String(body.jugadorId || body.robloxUserId || "0");
const motor = String(body.motor || "desconocido").toLowerCase();
const sdk = String(body.sdk || "desconocido").toLowerCase();

const respuesta = {
  ok: true,
  codigo: codigo,
  filas: R,
  columnas: C,
  minas: M,
  dificultad: dificultad,
  motor: motor,
  sdk: sdk,
  estado: "En curso",
  celdasReveladas: 0,
  puntaje: 0,
  sembrado: false,
  vista: vista,
  inicio: inicio
};

return {
  codigo: codigo,
  filas: R,
  columnas: C,
  minas: M,
  dificultad: dificultad,
  matriz: "[]",
  revelado: JSON.stringify(revelado),
  banderas: JSON.stringify(banderas),
  inicio: inicio,
  alias: alias,
  jugadorId: jugadorId,
  motor: motor,
  sdk: sdk,
  respuesta: JSON.stringify(respuesta)
};

Declara las salidas que devuelve el nodo, para que los nodos siguientes puedan referenciarlas: codigo, filas, columnas, minas, dificultad, matriz, revelado, banderas, inicio, alias, jugadorId, motor, sdk y respuesta. Todas cadenas salvo filas, columnas y minas, que son números.

Nodo 3 - Insert Rows, llamado guardar, apuntado a tu tabla Buscaminas Partidas. Mapea los campos:

Columna

Valor

Codigo

{{context.steps.generar.codigo}}

Alias

{{context.steps.generar.alias}}

Jugador Externo Id

{{context.steps.generar.jugadorId}}

Estado

En curso

Filas

{{context.steps.generar.filas}}

Columnas

{{context.steps.generar.columnas}}

Minas

{{context.steps.generar.minas}}

Dificultad

{{context.steps.generar.dificultad}}

Matriz

{{context.steps.generar.matriz}}

Revelado

{{context.steps.generar.revelado}}

Banderas

{{context.steps.generar.banderas}}

Celdas Reveladas

0

Puntaje

0

Inicio

{{context.steps.generar.inicio}}

Motor

{{context.steps.generar.motor}}

SDK

{{context.steps.generar.sdk}}

Nodo 4 - Response. Estado 200, content type application/json, plantilla del cuerpo {{context.steps.generar.respuesta}}.

Publica la Automation y enlázala al endpoint Buscaminas: Nueva Partida.

¿Por qué el Script devuelve una fila y además una `respuesta`? Porque son dos audiencias distintas. La fila guarda todo, incluida `matriz`. La `respuesta` es lo que sale del workspace, y no tiene `matriz` adentro. Construirlas por separado es lo que hace que la omisión sea deliberada y no accidental.



Paso 10 — La Automation `Validar Resultado`

Esta es la automation que sostiene el diseño entero. El motor local ya jugó la partida sin pedirle permiso a nadie; esto es lo único que impide que "local" signifique "el cliente decide y Praxsuite anota". No la vuelve a jugar: compara lo que el cliente reporta contra las dimensiones y la cantidad de minas que Praxsuite generó al abrir la partida, y verifica que el resultado sea internamente consistente con eso.

Son diez nodos, con una bifurcación:

Trigger → Buscar partida → Top 3 actual → Script: validar → ¿Es valido?
   ├── true  → Cerrar partida → Registrar en leaderboard → Vault → Publicar al bus → Responder
   └── false → Responder

Nodo 1 — Endpoint Trigger, apuntado a Buscaminas: Validar Resultado. Payload de prueba, con un tablero de 2×2 que se puede verificar a mano:

{
  "codigo": "2K2Z5B64",
  "estado": "Ganada",
  "matriz": [[0,0],[0,-1]],
  "revelado": [[1,1],[1,0]],
  "banderas": [[0,0],[0,1]],
  "puntaje": 0,
  "segundos": 12,
  "alias": "mirko",
  "jugadorId": "1",
  "motor": "minecraft",
  "sdk": "java"
}

Nodo 2 — Query Rows, llamado buscar, sobre Buscaminas Partidas. Filtro Codigo eq {{context.request.body.codigo}}, límite 1. Esta fila es la única fuente de verdad sobre cuántas minas tenía la partida.

Nodo 3 — Query Rows, llamado top3, sobre Demos Leaderboard. Filtro Juego eq Buscaminas, orden Points descendente, límite 3. Se consulta antes de insertar esta partida, para saber contra qué umbral compararla.

Nodo 4 — Script, llamado validar, JavaScript. Tres entradas: payload desde {{context.request.body}}, partida desde {{context.steps.buscar.row}} y top3rows desde {{context.steps.top3.rows}}.

// -- Buscaminas: validar resultado final ---------------------------------------
// Entradas:
//   payload   <- {{context.request.body}}        resultado final del motor local
//   partida   <- {{context.steps.buscar.row}}     fila de 'Buscaminas Partidas'
//   top3rows  <- {{context.steps.top3.rows}}      top 3 actual, ANTES de insertar esta partida
//
// El motor local (Minecraft/Roblox/Unity/web) ya jugo la partida entera sin red
// por click. Esto no la vuelve a jugar: usa filas/columnas/minas que Praxsuite
// genero en 'Buscaminas: Nueva Partida' -nunca lo que manda el cliente- como el
// unico punto de comparacion, y verifica que el resultado reportado sea
// internamente consistente con eso. Si algo no cierra no se escribe nada: ni el
// cierre de la partida ni el leaderboard.

const body = payload || {};
const p = partida || {};

function parseJ(v, fallback) {
  if (v === null || v === undefined) return fallback;
  if (typeof v === "string") {
    try { return JSON.parse(v); } catch (e) { return fallback; }
  }
  return v;
}

const R = Number(p.Filas) || 0;
const C = Number(p.Columnas) || 0;
const M = Number(p.Minas) || 0;
const rowId = String(p.ID || "");
const codigo = String(p.Codigo || "");
const estadoActual = (p.Estado && p.Estado.Name) ? p.Estado.Name : String(p.Estado || "En curso");

const motivos = [];
function fallar(m) { motivos.push(m); }

const existe = Boolean(codigo) && R > 0 && C > 0;
if (!existe) fallar("partida_no_encontrada");
if (existe && estadoActual !== "En curso") fallar("partida_ya_cerrada");

const estadoReportado = String(body.estado || "").trim();
if (estadoReportado !== "Ganada" && estadoReportado !== "Perdida") fallar("estado_invalido");

const matriz = parseJ(body.matriz, null);
const revelado = parseJ(body.revelado, null);
const banderas = parseJ(body.banderas, null);

function formaValida(g) {
  return Array.isArray(g) && g.length === R && g.every(function (fila) {
    return Array.isArray(fila) && fila.length === C;
  });
}

if (existe && motivos.length === 0) {
  if (!formaValida(matriz) || !formaValida(revelado) || !formaValida(banderas)) {
    fallar("forma_de_tablero_invalida");
  }
}

let minasReportadas = 0;
let reveladas = 0;
let minaRevelada = false;
let numerosConsistentes = true;

if (motivos.length === 0) {
  for (let r = 0; r < R; r++) {
    for (let c = 0; c < C; c++) {
      if (matriz[r][c] === -1) minasReportadas++;
    }
  }
  for (let r = 0; r < R; r++) {
    for (let c = 0; c < C; c++) {
      if (matriz[r][c] !== -1) {
        let n = 0;
        for (let dr = -1; dr <= 1; dr++) {
          for (let dc = -1; dc <= 1; dc++) {
            if (dr === 0 && dc === 0) continue;
            const rr = r + dr, cc = c + dc;
            if (rr >= 0 && rr < R && cc >= 0 && cc < C && matriz[rr][cc] === -1) n++;
          }
        }
        if (matriz[r][c] !== n) numerosConsistentes = false;
      }
      if (revelado[r][c] === 1) {
        reveladas++;
        if (matriz[r][c] === -1) minaRevelada = true;
      }
    }
  }

  if (minasReportadas !== M) fallar("cantidad_de_minas_no_coincide");
  if (!numerosConsistentes) fallar("numeros_de_la_matriz_inconsistentes");

  if (estadoReportado === "Ganada") {
    if (minaRevelada) fallar("gano_pero_hay_una_mina_revelada");
    if (reveladas !== R * C - M) fallar("gano_pero_no_revelo_todas_las_celdas_seguras");
  } else if (estadoReportado === "Perdida") {
    if (!minaRevelada) fallar("perdio_pero_ninguna_mina_esta_revelada");
  }
}

const segundos = Math.max(0, Math.min(36000, Number(body.segundos) || 0));

let puntajeEsperado = 0;
if (motivos.length === 0) {
  puntajeEsperado = reveladas * 10;
  if (estadoReportado === "Ganada") {
    puntajeEsperado += M * 50 + Math.max(0, 600 - segundos) * 2;
  }
  if (Number(body.puntaje) !== puntajeEsperado) fallar("puntaje_no_coincide");
}

const valido = motivos.length === 0;

const alias = String(body.alias || p.Alias || "anonimo");
const jugadorId = String(body.jugadorId || p.Jugador_Externo_Id || p["Jugador Externo Id"] || "0");
const motor = String(body.motor || p.Motor || "desconocido");
const sdk = String(body.sdk || p.SDK || "desconocido");
const dificultad = String(p.Dificultad || "facil");

// Umbral de top 3 ANTES de insertar esta partida: si hay menos de 3 filas
// todavia, cualquier puntaje valido entra.
const top3 = Array.isArray(top3rows) ? top3rows : [];
const umbral = top3.length >= 3 ? (Number(top3[top3.length - 1].Points) || 0) : -1;
const esTop3 = valido && Number(body.puntaje) > umbral;

const fin = new Date().toISOString();

const respuesta = {
  ok: valido,
  codigo: codigo,
  estado: valido ? estadoReportado : estadoActual,
  puntaje: valido ? Number(body.puntaje) : 0,
  motivos: motivos,
  mensaje: valido ? "Resultado validado" : ("Resultado rechazado: " + motivos.join(", "))
};

return {
  valido: valido ? "si" : "no",
  rowId: rowId,
  codigo: codigo,
  alias: alias,
  jugadorId: jugadorId,
  motor: motor,
  sdk: sdk,
  dificultad: dificultad,
  estado: estadoReportado,
  matriz: JSON.stringify(matriz),
  revelado: JSON.stringify(revelado),
  banderas: JSON.stringify(banderas),
  celdasReveladas: String(reveladas),
  puntaje: String(valido ? Number(body.puntaje) : 0),
  segundos: String(segundos),
  fin: fin,
  esTop3: esTop3 ? "si" : "no",
  respuesta: JSON.stringify(respuesta)
};

Declara las dieciocho salidas: valido, rowId, codigo, alias, jugadorId, motor, sdk, dificultad, estado, matriz, revelado, banderas, celdasReveladas, puntaje, segundos, fin, esTop3 y respuesta.

Nodo 5 — If/Else, llamado esvalido. Una regla: {{context.steps.validar.valido}} == si.

Nodo 6 — Update Rows (rama true), llamado cerrar, sobre Buscaminas Partidas, con rowId = {{context.steps.validar.rowId}}:

Columna

Valor

Estado

{{context.steps.validar.estado}}

Matriz

{{context.steps.validar.matriz}}

Revelado

{{context.steps.validar.revelado}}

Banderas

{{context.steps.validar.banderas}}

Celdas Reveladas

{{context.steps.validar.celdasReveladas}}

Puntaje

{{context.steps.validar.puntaje}}

Segundos

{{context.steps.validar.segundos}}

Fin

{{context.steps.validar.fin}}

Nodo 7 — Insert Rows, llamado marcador, sobre Demos Leaderboard:

Columna

Valor

Record

Buscaminas {{context.steps.validar.codigo}} ({{context.steps.validar.estado}})

Alias

{{context.steps.validar.alias}}

Jugador Externo Id

{{context.steps.validar.jugadorId}}

Points

{{context.steps.validar.puntaje}}

Dificultad

{{context.steps.validar.dificultad}}

Segundos

{{context.steps.validar.segundos}}

Juego

Buscaminas

Motor

{{context.steps.validar.motor}}

SDK

{{context.steps.validar.sdk}}

Nodo 8 — Vault, llamado vaultbus. Alias busKey, apuntado al secreto donde guardaste una API key con permiso de publicar al Event Bus.

Nodo 9 — HTTP Request, llamado publicartop3. POST a la ruta de publicación del bus de tu workspace:

https://gateway.praxsuite.com/api/v1/gateway/<tu-workspace-uuid>/bus/leaderboard/buscaminas/publish

Cabeceras x-api-key: {{vault.busKey}} y Content-Type: application/json. Cuerpo:

{"event":"game_completed","payload":{"alias":"{{context.steps.validar.alias}}","puntaje":{{context.steps.validar.puntaje}},"estado":"{{context.steps.validar.estado}}","dificultad":"{{context.steps.validar.dificultad}}","motor":"{{context.steps.validar.motor}}","codigo":"{{context.steps.validar.codigo}}","esTop3":"{{context.steps.validar.esTop3}}"}}

Activa `continueOnError` en este nodo. Es lo que hace que cerrar la partida y escribir el leaderboard nunca dependan de que el bus esté disponible: el aviso es estrictamente un extra sobre una escritura que ya ocurrió.

El evento se llama `game_completed`, y se publica en toda partida validada — no solo en las del podio. Quién entró al top viaja como el campo `esTop3` del payload, no como el nombre del evento. Un suscriptor que quiera anunciar solo los récords filtra por ese campo; uno que quiera mostrar toda la actividad no filtra nada. Si vienes de una versión anterior de estas guías que hablaba de un evento `topscore`, ese es el nombre que hay que corregir del lado del suscriptor.*

Nodo 10 — Response. Estado 200, application/json, cuerpo {{context.steps.validar.respuesta}}. Las dos ramas terminan acá: la inválida responde con ok: false y la lista de motivos, sin haber escrito nada.

Publica la automation y enlázala al endpoint Buscaminas: Validar Resultado.

¿Qué previene esto en realidad? No a un cliente que mienta en todo — un tramposo decidido igual podría inventar un tablero internamente consistente. Lo que sí previene es exactamente el modo de falla de "confiar en el cliente": un error del motor local, o un binario modificado a propósito, reportando un tablero imposible (más minas de las que la partida abrió, números que no coinciden con el trazado, un puntaje que la aritmética no sostiene) y que eso entre en silencio a un leaderboard donde aparecen las partidas reales de todos los demás.


Paso 11 — La Automation `Leaderboard`

Cuatro nodos otra vez:

Trigger de endpoint  →  Query rows  →  Script: formatear  →  Response

Nodo 1 - Trigger de endpoint, apuntado a Buscaminas: Leaderboard. Payload de prueba { "limite": 10 }.

Nodo 2 - Query Rows, llamado consultar, sobre Demos Leaderboard. Filtro: Juego eq Buscaminas. Ordenar por Points descendente. Límite 50.

Nodo 3 - Script, llamado formatear. Dos entradas: filas desde {{context.steps.consultar.rows}} y payload desde {{context.request.body}}. Una salida: respuesta.

// Toma las filas crudas del leaderboard y devuelve un top listo para pintar.
//
// La clave de deduplicacion es alias + motor, no solo alias: la gracia de este
// marcador es ver al mismo jugador entrando desde Roblox, desde Unity y desde
// el navegador, y comparar. Si dedujeramos solo por alias, la mejor partida
// taparia a las otras dos.
//
// El cuerpo acepta { limite, motor }: pasando `motor` se filtra a un solo
// front-end, util para las demos individuales de cada SDK.

const body = payload || {};
const limite = Math.max(1, Math.min(25, Number(body.limite) || 10));
const filtroMotor = body.motor ? String(body.motor).toLowerCase() : null;

const rows = Array.isArray(filas) ? filas : [];

const mejorPorClave = new Map();
let contados = 0;

for (const r of rows) {
  const motor = String(r.Motor || "desconocido").toLowerCase();
  if (filtroMotor && motor !== filtroMotor) continue;
  contados++;

  const alias = String(r.Alias || r.Jugador_Externo_Id || "anonimo");
  const clave = alias + "|" + motor;
  const puntos = Number(r.Points) || 0;

  const previo = mejorPorClave.get(clave);
  if (!previo || puntos > previo.puntos) {
    mejorPorClave.set(clave, {
      alias: alias,
      puntos: puntos,
      motor: motor,
      sdk: String(r.SDK || "desconocido"),
      dificultad: String(r.Dificultad || ""),
      segundos: Number(r.Segundos) || 0,
      jugadorId: String(r.Jugador_Externo_Id || r["Jugador Externo Id"] || "0")
    });
  }
}

const top = Array.from(mejorPorClave.values())
  .sort((a, b) => b.puntos - a.puntos)
  .slice(0, limite)
  .map((e, i) => Object.assign({ posicion: i + 1 }, e));

// Cuantas partidas aporto cada motor, para el pie del marcador.
const porMotor = {};
for (const r of rows) {
  const m = String(r.Motor || "desconocido").toLowerCase();
  if (filtroMotor && m !== filtroMotor) continue;
  porMotor[m] = (porMotor[m] || 0) + 1;
}

return {
  respuesta: JSON.stringify({
    ok: true,
    total: contados,
    porMotor: porMotor,
    top: top
  })
};

Nodo 4 - Response, plantilla del cuerpo {{context.steps.formatear.respuesta}}.

Publica, enlaza, y el backend está listo.

Fíjate en el nombre del campo. Cada entrada de `top` trae `puntos`, no `puntaje`. `puntaje` es el puntaje de la partida dentro de `Jugar`; `puntos` es el de la entrada del leaderboard. Leer el equivocado te da `nil` en Lua, en silencio.



Paso 12 — La Automation `Asignar Rol Jugador`

El rol del Paso 6 ya existe, pero nadie se lo da a nadie todavía. La opción del portal — "roles con los que nace una cuenta nueva", en el proveedor — funciona, y es una sola respuesta estática por proveedor: cada plataforma que agregues repite el mismo paso a mano. El patrón que sí escala es una automation detrás de un endpoint Sync que cualquier plataforma llama igual, justo después de abrir sesión.

Cuatro nodos:

Trigger → Validate End User Token → Manage End User Roles → Response

Nodo 1 — Endpoint Trigger (Sync), apuntado a Buscaminas: Asignar Rol Jugador. Payload de prueba: { "token": "<un access token de prueba>" }.

Nodo 2 — Validate End User Token, con token = {{context.request.body.token}}. Este es el nodo que hace todo seguro: resuelve a quien llama, ya verificado, a partir de su propio access token, así que nada más adelante tiene que confiar en un id que el cliente diga ser.

Nodo 3 — Manage End User Roles, con action: assign, endUserId: {{context.steps.<paso-de-validacion>.endUserId}} y roleIds: [<el id del rol Buscaminas Jugador>].

Nodo 4 — Response, una confirmación JSON breve.

Desde el plugin, justo después de assertPlayer:

praxAuth.endpoints().call(ASSIGN_ROLE_ENDPOINT_ID, Map.of("token", sesion.accessToken()));

Como la automation valida el token en vez de confiar en un parámetro, este mismo endpoint ya sirve para Roblox, Unity o lo que venga después — ninguno necesita una ruta de código específica de Minecraft, y Minecraft no necesitó ninguna de las de ellos.

¿Por qué no mandar el `endUserId` y listo? Porque un endpoint no autentica a quien lo llama por sí solo: un POST sin credencial igual llega a la automation. Si el id viniera en el cuerpo, cualquiera podría pedir el rol para cualquier cuenta. El token, en cambio, solo lo tiene quien acaba de iniciar sesión como esa cuenta.


Paso 13 — El topic del Event Bus

En Event Bus, crea un topic con clave `leaderboard`. Con acceso Workspace alcanza: cualquier end user con sesión iniciada puede unirse, que es exactamente lo que va a hacer la identidad de bot del Paso 18.

El topic define un patrón de clave leaderboard:{instance}, y la instancia que usa este juego es buscaminas — de ahí sale el par topic("leaderboard").channel("buscaminas") del lado del plugin.

Falta el secreto que usa el Nodo 8 de Validar Resultado: en Vault, guarda una API key del workspace con permiso de publicar al bus, y anota su id para apuntar ahí el nodo.

No hace falta que nadie escuche todavía. Una automation puede publicar a un topic sin oyentes durante mucho tiempo antes de que aparezca uno — de hecho, así estuvo este topic hasta que Minecraft se volvió la primera plataforma en suscribirse.


Punto de control — Prueba el backend antes de escribir una línea de Java

Los cuatro endpoints ya existen y responden. Pruébalos ahora, desde Gateway → Playground o con curl, porque depurar una automation desde adentro de un plugin de Paper es mucho más lento que depurarla sola:

  1. `Nueva Partida` con {"alias":"prueba","jugadorId":"1","dificultad":"facil","motor":"minecraft","sdk":"java"}. Tiene que devolver un codigo de 8 caracteres, filas 8, columnas 8, minas 10 — y ninguna matriz en la respuesta. Si la matriz sale del workspace, revisa el Nodo 4 de esa automation.

  2. `Validar Resultado` con el payload de prueba del Paso 10, cambiando codigo por el que acabas de recibir. Con ese tablero de 2×2 contra una partida de 8×8 tiene que responder ok: false y, entre los motivos, forma_de_tablero_invalida. Que rechace es el resultado correcto: significa que está comparando de verdad contra la fila guardada.

  3. `Leaderboard` con {"limite":10}. Va a devolver una lista vacía o las partidas de otras plataformas. Ambas cosas están bien.

  4. `Asignar Rol Jugador` sin token. Tiene que fallar. Si asigna el rol igual, el Nodo 2 no está validando nada.

Recién cuando los cuatro se comporten así vale la pena volver al plugin.


Parte 3 — El plugin

Paso 14 — Abrir una partida

Nueva Partida reserva la fila y devuelve las dimensiones del tablero — todavía nada de las minas; esas se siembran en la primera revelación, así el primer clic nunca puede ser una derrota inevitable.

Map<String, Object> resultado = prax.endpoints().call(endpointNuevaPartidaId, Map.of(
        "jugadorId", player.getUniqueId().toString(),
        "alias", player.getName(),
        "dificultad", "facil",
        "motor", "minecraft",
        "sdk", "java"));

String codigo = String.valueOf(resultado.get("codigo"));
int filas = ((Number) resultado.get("filas")).intValue();
int columnas = ((Number) resultado.get("columnas")).intValue();
int minas = ((Number) resultado.get("minas")).intValue();

motor y sdk no son decorativos — son lo que permite que el leaderboard compartido muestre de qué plataforma vino cada puntaje alto, y lo que un futuro panel por dificultad podría filtrar.


Paso 15 — Dibujar el tablero con bloques

Cada celda es un bloque del mundo. Una celda oculta, una bandera, y cada número revelado tienen su propio Material:

private static Material materialParaCelda(char celda) {
    return switch (celda) {
        case 'F' -> Material.TARGET;             // bandera
        case '*' -> Material.TNT;                // una mina, solo aparece al perder
        case '0' -> Material.WHITE_CONCRETE;
        case '1' -> Material.LIGHT_BLUE_CONCRETE;
        case '2' -> Material.LIME_CONCRETE;
        case '3' -> Material.RED_CONCRETE;
        // ... un color por cada cantidad restante, siguiendo la paleta clasica del Minesweeper
        default -> Material.STONE;               // oculta
    };
}

¿Por qué bloques y no cabezas de jugador con textura de número? Una cabeza con textura necesita un valor base64 real, obtenido de algún lugar como minecraft-heads.com, por cada dígito. Un valor incorrecto o inalcanzable falla en silencio — la cabeza simplemente se ve en blanco — así que un bloque de color es la versión que no puede romperse sin avisar. Cambiar esta única función por cabezas con textura más adelante no afecta nada más del plugin.

Dibujar el tablero son dos bucles anidados escribiendo directo en el mundo:

World world = origin.getWorld();
for (int fila = 0; fila < filas; fila++) {
    for (int columna = 0; columna < columnas; columna++) {
        world.getBlockAt(origin.getBlockX() + columna, origin.getBlockY(), origin.getBlockZ() + fila)
                .setType(materialParaCelda(caracterDeVista(fila, columna)));
    }
}

Paso 16 — Resolver movimientos localmente

Esta es la única lógica de juego real que vive en el plugin, y tiene que producir exactamente los mismos números que recalcula Validar Resultado en el Paso 10 — siembra de minas alrededor de una zona segura en el primer clic, flood-fill iterativo al revelar una celda vacía, y la misma fórmula de puntaje. Captura los clics con PlayerInteractEvent, no con BlockBreakEvent — los bloques del tablero nunca deben romperse de verdad:

@EventHandler
public void onPlayerInteract(PlayerInteractEvent event) {
    if (event.getHand() != EquipmentSlot.HAND) return;
    Action action = event.getAction();
    if (action != Action.LEFT_CLICK_BLOCK && action != Action.RIGHT_CLICK_BLOCK) return;

    Block clicado = event.getClickedBlock();
    // ... resolver a que (fila, columna) de la partida activa corresponde este bloque, si corresponde a alguna
    event.setCancelled(true); // nunca romper ni colocar el bloque real

    if (action == Action.RIGHT_CLICK_BLOCK) {
        alternarBandera(partida, fila, columna);
    } else {
        revelar(partida, fila, columna); // siembra, flood-fill, victoria/derrota - todo en memoria
    }
}

Como nada de esto toca la red, un clic es visualmente instantáneo — la ida y vuelta que el diseño por clic de Roblox paga cada vez, aquí directamente no existe.

¿Por qué no dejamos las minas en Praxsuite todo el tiempo, como hace Roblox? Sí se hace, hasta la primera revelación — `Nueva Partida` deliberadamente no devuelve el trazado de minas. Desde el primer clic en adelante, se confía en el plugin con ese trazado, de la misma forma en que se confía en un servidor de Roblox con la API key. El paso siguiente es lo que evita que esa confianza se convierta en un vector de trampa.


Paso 17 — Validar el resultado al terminar la partida

En el instante en que una partida llega a Ganada o Perdida, el plugin reporta el tablero final entero — no solo el resultado — a Validar Resultado. La automation recalcula la cantidad de minas, recalcula el número de cada celda a partir de la posición real de las minas en la matriz, y recalcula el puntaje esperado, y se niega a cerrar la partida (sin leaderboard, sin nada) si alguno de esos cálculos no coincide con lo reportado:

Map<String, Object> body = new LinkedHashMap<>();
body.put("codigo", codigo);
body.put("estado", estado);                 // "Ganada" o "Perdida"
body.put("matriz", aListasAnidadas(matriz)); // -1 para una mina, si no su cantidad de vecinas
body.put("revelado", aListasAnidadas(revelado));
body.put("banderas", aListasAnidadas(banderas));
body.put("puntaje", puntaje);
body.put("segundos", segundosTranscurridos);
body.put("alias", player.getName());
body.put("jugadorId", player.getUniqueId().toString());
body.put("motor", "minecraft");
body.put("sdk", "java");

Map<String, Object> resultado = prax.endpoints().call(endpointValidarResultadoId, body);
boolean ok = Boolean.TRUE.equals(resultado.get("ok"));

El puntaje tiene que coincidir de forma exacta — celdasReveladas * 10, más minas * 50 + max(0, 600 - segundos) * 2 si ganó. Si esa fórmula está apenas mal, todas las partidas se rechazan en silencio: ok vuelve false con un mensaje explicando qué verificación falló, y no se escribe nada.

¿Qué previene esto en realidad? No a un cliente que mienta en todo — un tramposo decidido igual podría inventar un tablero internamente consistente. Lo que sí previene es exactamente el modo de falla de "confiar en el cliente": un error del plugin, o un jar modificado a propósito, reportando un tablero imposible (más minas de las que la partida abrió, números que no coinciden con el trazado de minas, un puntaje que la aritmética no sostiene) y que eso entre en silencio a un leaderboard donde aparecen las partidas reales de todos los demás.

medium-game-lost.png

Paso 18 — El Event Bus: un leaderboard en vivo

Validar Resultado ya publica al bus leaderboard:buscaminas — lo construiste en el Paso 10. El evento se llama `game_completed` y sale en toda partida validada, con un campo esTop3 en el payload que distingue las del podio. Ese topic estuvo publicando sin oyentes desde que se construyó para la versión de Roblox; aquí es donde Minecraft se convierte en la primera plataforma en escucharlo de verdad.

El requisito que cambia cómo te conectas: una sesión "ambient"

prax.bus() se autentica leyendo client.auth().session() — la misma sesión "ambient" que login() instala para una aplicación de un solo usuario. assertPlayer deliberadamente nunca toca ese campo (el Paso 3 de la guía de implementación explica por qué), así que escuchar el bus necesita una sesión instalada a propósito, con el método pensado justo para eso:

PraxAuth.Session sesionDelBot = praxBus.auth()
        .assertPlayer("minecraft", "buscaminas-server-listener", "Buscaminas Server");
praxBus.auth().adopt(sesionDelBot);

adopt(session) instala una sesión ya obtenida como la sesión ambient de ese cliente.

Por qué esto necesita su propia instancia de `Praxsuite`

Si se le hiciera adopt(...) a la misma instancia de Praxsuite que el plugin ya usa para assertPlayer/endpoints() de los jugadores reales, todas esas llamadas empezarían a autenticarse como esta identidad de bot en vez de con la server key — porque el cliente prefiere una sesión ambient sobre la credencial configurada apenas hay una instalada. Esa falla es silenciosa y total: no solo el bus, todo. La solución es una segunda instancia, dedicada, que nunca se usa para nada más:

private Praxsuite praxBus;

private void conectarLeaderboard() {
    Bukkit.getScheduler().runTaskAsynchronously(this, () -> {
        praxBus = Praxsuite.builder()
                .workspaceId("tu-workspace-uuid")
                .credential("sk_live_...")
                .build();

        PraxAuth.Session sesionDelBot = praxBus.auth()
                .assertPlayer("minecraft", "buscaminas-server-listener", "Buscaminas Server");
        praxBus.auth().adopt(sesionDelBot);

        PraxChannel canal = praxBus.bus().topic("leaderboard").channel("buscaminas");
        canal.on("game_completed", this::anunciarPartida);
        canal.join();
    });
}

canal.join() lanza una excepción si el join es rechazado — más notorio que un publish() rechazado, deliberadamente: un publish perdido es un mensaje menos, pero un join que falla en silencio deja a todo el plugin escuchando nada durante toda su vida útil.

Los handlers corren en el hilo del WebSocket

private void anunciarPartida(PraxChannel.BusEvent evento) {
    if (!(evento.payload() instanceof Map<?, ?> payload)) return;
    String alias = String.valueOf(payload.get("alias"));
    Object puntaje = payload.get("puntaje");

    Bukkit.getScheduler().runTask(this, () ->
            Bukkit.broadcastMessage("§6[Buscaminas] §e" + alias + " §7entró al top con §a" + puntaje));
}

Cualquier cosa que toque la API de Bukkit — anunciar en el chat, actualizar un scoreboard, mover una entidad — tiene que volver primero al hilo principal. El payload en sí es JSON opaco, sin interpretar, retransmitido entre usuarios; trátalo como un dato para mostrar, nunca como insumo de una decisión que importe — la misma regla que aplica al bus en cualquier otro lugar de la plataforma.

Un leaderboard visible: el scoreboard sidebar

Combina la suscripción al bus con el sidebar propio de Bukkit (el panel a la derecha que ve cualquier jugador) para algo que se nota sin tener que leer el chat:

Scoreboard tabla = Bukkit.getScoreboardManager().getNewScoreboard();
Objective objetivo = tabla.registerNewObjective("buscaminas", Criteria.DUMMY, "§6Buscaminas - Top");
objetivo.setDisplaySlot(DisplaySlot.SIDEBAR);
// asignar `tabla` a cada jugador al conectarse con player.setScoreboard(tabla)

Complétalo una vez al arrancar desde el endpoint Leaderboard (sin autenticación), y vuelve a dibujarlo cada vez que se dispare anunciarPartida — el valor numérico propio del scoreboard duplica como clave de orden, así que establecer objetivo.getScore(alias).setScore(puntos) para cada uno del top 10 muestra y ordena correctamente sin ninguna cuenta extra.

Una limitación conocida: el refresco de token en una reconexión larga

El bus reconecta solo ante un corte de red, pero relee lo que sea que session.accessToken() tenga guardado en ese momento — no lo refresca antes. Una identidad de bot pensada para permanecer conectada durante toda la vida útil del servidor debería volver a llamar assertPlayer y adopt con un temporizador (un BukkitRunnable cada pocos minutos supera cómodamente la vida útil del access token), en vez de asumir que una sola sesión dura para siempre.

imagen_2026-09-21_223803182.png

Por qué local, y no una automation por clic

Es la misma pregunta que la versión de Roblox de este juego responde en su propia guía, llegando a un punto distinto del mismo equilibrio, por una razón específica de Minecraft: el propio tick loop de un plugin de Paper ya es infraestructura de baja latencia con la que un round-trip de RemoteFunction de Roblox a través del Gateway simplemente no compite en igualdad de condiciones. Los criterios que lo deciden, en general:

Favorece una automation

Favorece local

Se dispara con poca frecuencia (login, abrir una partida, cerrarla)

Se dispara en cada entrada del jugador (un clic, una tecla)

Más de un motor necesita el mismo resultado exacto

Solo este plugin lo necesita, o divergir por ahora es aceptable

Necesita otros efectos del lado de Praxsuite en la misma transacción (un correo, un publish al bus, otra tabla)

El estado que hace falta para decidir ya está completo del lado local

Se quiere poder cambiar la regla sin publicar un jar nuevo

—

Mover la colocación de minas y el flood-fill a Java no le quitó autoridad a Praxsuite sobre el resultado — Validar Resultado sigue recalculando todo y puede rechazar la partida entera. Lo que cambió es cuándo verifica Praxsuite: una sola vez, al final, en vez de una vez por clic. Ese es todo el intercambio — y por eso el Paso 6 de arriba no es opcional aunque lo parezca: omitirlo convierte a "local" en silencio en "ahora el cliente es la autoridad, y punto".


Errores Comunes y Cómo Evitarlos

Error

Causa

Solución

BUS_REQUIRES_SESSION

Se llamó connect()/join() antes de adopt(session).

Llama a assertPlayer + adopt en la instancia de Praxsuite dedicada al bus, primero.

Todo lo demás en el plugin empieza a fallar apenas se conecta el bus

Se llamó adopt(...) sobre la misma instancia que se usa para assertPlayer/endpoints() de los jugadores reales.

Usa una segunda instancia de Praxsuite, dedicada exclusivamente al bus.

Validar Resultado rechaza una partida que en el juego se ve bien

La fórmula de puntaje local (o el flood-fill) divergió del script de la automation — casi siempre la aritmética del puntaje.

Compara tu código Java contra el nodo Script del Paso 10 línea por línea; tienen que producir números idénticos para tableros idénticos.

canal.join() lanza una excepción

El topic leaderboard todavía no existe, o su regla de acceso rechaza esta sesión.

Revisa el Paso 13: el topic leaderboard tiene que existir, con acceso Workspace.

El tablero se dibuja, pero hacer clic no hace nada

PlayerInteractEvent se disparó dos veces (una por mano) y la segunda no encontró partida, o el clic cayó fuera de los límites calculados del tablero.

Filtra por EquipmentSlot.HAND; revisa el cálculo de origen/desplazamiento contra donde realmente se dibujó el tablero.


Consejos de Producción

  • Reutiliza el mismo backend desde todas las plataformas — un Buscaminas Partidas y un Demos Leaderboard compartidos son lo que le da sentido a un leaderboard entre plataformas.

  • Asigna roles vía una automation que valide el token, no un valor por defecto estático por proveedor — es la versión que no se repite para la próxima plataforma. Y revisa los scopes del Paso 6 cada vez que agregues una columna a Buscaminas Partidas: una columna nueva no hereda la decisión de mantener Matriz cerrada.

  • Nunca dejes que la identidad del Event Bus comparta instancia de Praxsuite con nada más.

  • Mantén el motor local como una traducción fiel, literal, del script de la automation. Los dos tienen que coincidir de forma exacta; "parecido" falla en silencio en Validar Resultado.

  • Refresca periódicamente la sesión del listener del bus si necesita durar más que la vida útil corta de un access token.


Próximos Pasos

  • Agrega un sistema de códigos canjeables o cosméticos igual que la demo de Roblox — como automations detrás de endpoints, reutilizadas por cualquier plataforma.

  • Extiende el listener del Event Bus para retransmitir también presencia (onPeerJoined/onPeerLeft) si algún día hace falta un lobby entre plataformas.

  • Si algún cliente futuro no puede tener motor local, construye también la automation Buscaminas: Jugar — resuelve una jugada por llamada y está documentada paso a paso en las guías de Roblox, Unity y TypeScript. Convive sin problema con el camino de esta guía.

Ya tienes un juego cuya única lógica de confianza del lado del cliente es exactamente la que tenía que estarlo, verificada contra un servidor que nunca simplemente le cree.