El mayor portal de MU Online de Brasil — desde 2003
Tutorial Avanzado Sitio web

Cómo crear una tienda de ítems web (webshop) para MU Online

Guía completa para construir una webshop segura para tu servidor de MU Online, integrando saldo de créditos, entrega automática de ítems en el baúl y protección contra fraudes.

GA Gabriel · Actualizado el 28 jun 2026 · ⏱ 16 min de lectura
Respuesta rápida

Una webshop (tienda de ítems web) es uno de los recursos que más diferencia a un servidor de MU Online amateur de un proyecto profesional. Permite que el jugador cambie créditos —comprados mediante donación o ganados en eventos— por ítems, alas, kits y servicios directamente desde el navegador, sin

Una webshop (tienda de ítems web) es uno de los recursos que más diferencia a un servidor de MU Online amateur de un proyecto profesional. Permite que el jugador cambie créditos —comprados mediante donación o ganados en eventos— por ítems, alas, kits y servicios directamente desde el navegador, sin depender de un GM conectado. Bien construida, funciona 24 horas, reduce el trabajo manual del equipo y se convierte en una de las principales fuentes de ingresos y retención del servidor. Mal construida, se vuelve un agujero de seguridad que reparte ítems infinitos, corrompe personajes y permite fraude de saldo.

Este tutorial es avanzado porque una webshop toca las tres partes más sensibles de tu ecosistema al mismo tiempo: la base de datos de cuentas/personajes, el formato binario de los ítems de tu Season y la capa web expuesta a internet. Vamos a montar una arquitectura correcta desde cero, con entrega segura, transacciones atómicas y protección contra los fraudes clásicos. Si todavía no tienes el servidor en línea, empieza por la guía base en cómo crear un servidor de MU Online y vuelve aquí después.

Requisitos previos

Antes de escribir una línea de código de la tienda, asegúrate de tener el entorno y el conocimiento mínimos:

  • Servidor de MU Online funcional (GameServer, ConnectServer y DataServer en línea).
  • Base de datos accesible desde la aplicación web — SQL Server (MuOnline/MuOnline DB) en la mayoría de las Seasons, o MySQL/MariaDB en distros modernas.
  • Sitio web ya publicado con PHP 8.1+ (o el stack que uses) y conexión a la base de datos funcionando.
  • Sistema de cuentas con login funcional en el sitio y un campo de saldo/créditos (WCoin, Credits, Cash o columna equivalente).
  • Conocimiento del formato de ítem de tu Season — índice, tipo, nivel, opciones, excellent, ancient, luck y skill.
  • Entorno de pruebas (una cuenta y un personaje descartables) para validar entregas antes de liberarlas a los jugadores.

> Aviso de seguridad: nunca desarrolles la webshop directamente en la base de datos de producción. Un error en un INSERT de ítem puede corromper personajes reales. Trabaja siempre con un dump de prueba primero.

Cómo entrega ítems realmente una webshop

Existe una confusión común: mucha gente cree que la webshop "habla con el servidor". En la práctica, la abrumadora mayoría de las tiendas web de MU no conversa directamente con el GameServer — escribe el ítem en la tabla de baúl (warehouse) de la base de datos, y el GameServer lee esa tabla cuando el jugador abre el baúl.

Esto tiene una consecuencia crítica: el ítem solo aparece de forma confiable cuando el personaje está offline. Mientras el jugador está logueado, el servidor mantiene una copia del inventario y del baúl en memoria. Si escribes en la base de datos en ese momento, el servidor lo sobrescribe todo al guardar en el logout, y el ítem comprado desaparece — generando ticket, enojo y pedido de reembolso.

Por eso, la arquitectura correcta es asíncrona:

  1. El jugador compra en la web; el saldo se debita y se registra un pedido.
  2. La entrega va a una cola (tabla webshop_delivery_queue).
  3. Un proceso verifica si el personaje está offline.
  4. Cuando está offline, el ítem se escribe en el baúl dentro de una transacción y el pedido se marca como entregado.
Modelo de entregaCómo funcionaProsContras
Escritura directa en el baúl (offline)La tienda inserta el ítem en la tabla warehouseSimple, sin tocar el sourceSolo funciona con el jugador desconectado
Cola + workerPedido encolado y procesado por un servicioSeguro, auditable, escalableExige un proceso programado en ejecución
Socket en tiempo realLa web envía un comando al GameServer vía source personalizadoEntrega instantánea onlineRequiere editar y recompilar el source
Cupón/canje in-gameLa web genera un código, el jugador lo canjea vía NPC/comandoCero escritura directa en la base de datosFricción para el jugador

Para el 90% de los servidores, el modelo cola + worker es el correcto. Es el que vamos a montar.

Paso 1 — Modelar las tablas de la tienda

Crea tablas propias de la webshop, separadas de las tablas del juego. Nunca reutilices tablas de MU para el control de la tienda. Ejemplo en SQL Server (ajusta los tipos para MySQL si es tu caso):

-- Catálogo de productos de la tienda
CREATE TABLE WebshopProducts (
    ProductId      INT IDENTITY(1,1) PRIMARY KEY,
    Name           NVARCHAR(120)  NOT NULL,
    Category       NVARCHAR(40)   NOT NULL,
    PriceCredits   INT            NOT NULL,
    ItemHex        VARCHAR(64)    NOT NULL,  -- hex del ítem para la Season
    DurabilityQty  INT            NOT NULL DEFAULT 1,
    Active         BIT            NOT NULL DEFAULT 1,
    CreatedAt      DATETIME       NOT NULL DEFAULT GETDATE()
);

-- Pedidos (auditoría e idempotencia)
CREATE TABLE WebshopOrders (
    OrderId        BIGINT IDENTITY(1,1) PRIMARY KEY,
    AccountId      VARCHAR(20)  NOT NULL,
    CharName       VARCHAR(20)  NOT NULL,
    ProductId      INT          NOT NULL,
    PriceCredits   INT          NOT NULL,
    IdemToken      CHAR(36)     NOT NULL UNIQUE, -- evita compra duplicada
    Status         VARCHAR(20)  NOT NULL DEFAULT 'PAID', -- PAID/QUEUED/DELIVERED/FAILED
    IpAddress      VARCHAR(45)  NULL,
    CreatedAt      DATETIME     NOT NULL DEFAULT GETDATE()
);

-- Cola de entrega
CREATE TABLE WebshopDeliveryQueue (
    QueueId    BIGINT IDENTITY(1,1) PRIMARY KEY,
    OrderId    BIGINT     NOT NULL,
    CharName   VARCHAR(20) NOT NULL,
    ItemHex    VARCHAR(64) NOT NULL,
    Attempts   INT        NOT NULL DEFAULT 0,
    Delivered  BIT        NOT NULL DEFAULT 0,
    CreatedAt  DATETIME   NOT NULL DEFAULT GETDATE()
);

La columna IdemToken es el corazón de la protección contra duplicidad: cada intento de compra genera un UUID; el índice UNIQUE garantiza que el mismo intento nunca se convierta en dos pedidos, incluso si el jugador hace doble clic o la conexión se cae y él reenvía.

Paso 2 — Entender el formato del ítem de tu Season

Este es el punto que más rompe webshops. Cada ítem en MU se representa con una secuencia binaria con campos en offsets fijos. El formato varía por versión — abajo hay un EJEMPLO didáctico basado en el layout clásico de 32 bytes (estilo Season 6); confirma los offsets en la documentación de tu distro antes de usarlo en producción.

Campo (ejemplo)Bytes aprox.Significado
Index/Section0-1Qué ítem (ej.: 0x0007 = espada X)
Level1Nivel de refinamiento +0 a +15
Options (dur)2Opción adicional / durabilidad
Excellent flags1Bitmask de las opciones excellent
Ancient/Set1Ítem ancient y bonus de set
Luck/SkillbitsFlags de luck y skill
Serial4-8Serial único del ítem

Un builder en PHP que arme el hex a partir de parámetros legibles hace que el registro de productos sea mucho menos propenso a errores:

<?php
// EJEMPLO simplificado — los offsets REALES varían por versión/Season.
function buildItemHex(array $it): string {
    $index   = $it['index']   ?? 0;   // código del ítem
    $level   = $it['level']   ?? 0;   // 0..15
    $skill   = !empty($it['skill'])  ? 1 : 0;
    $luck    = !empty($it['luck'])   ? 1 : 0;
    $option  = $it['option']  ?? 0;   // 0..3 (opción +4/+8/...)
    $exc     = $it['exc']     ?? 0;   // bitmask excellent (0..63)

    // Byte de nivel: normalmente level << 3 combinado con el bit de skill
    $lvlByte = (($level & 0x0F) << 3) | ($skill << 7);
    // Byte de opción: option en los bits bajos + luck
    $optByte = ($option & 0x03) | ($luck ? 0x04 : 0x00);

    $bytes = [
        $index & 0xFF,
        $lvlByte & 0xFF,
        $optByte & 0xFF,
        $exc & 0xFF,
    ];
    return strtoupper(bin2hex(pack('C*', ...$bytes)));
}

echo buildItemHex(['index'=>7,'level'=>11,'exc'=>0x3F,'luck'=>true,'skill'=>true]);

> Importante: el código de arriba es ilustrativo. No copies los offsets a ciegas — toma el layout correcto de tu Season (la documentación de la distro o el propio source ItemManager/ItemConvert). Un offset equivocado genera un ítem inválido que traba el baúl del jugador.

Paso 3 — El flujo de compra con transacción atómica

La compra necesita ser atómica: debitar el saldo y registrar el pedido tienen que ocurrir juntos o no ocurrir. Nunca debites crédito en un comando e insertes el pedido en otro sin transacción — una falla en el medio deja al jugador sin crédito y sin ítem.

<?php
function comprarItem(PDO $pdo, string $accountId, string $charName, int $productId): array {
    $idem = bin2hex(random_bytes(16)); // token de idempotencia
    $idem = substr(preg_replace('/(.{8})(.{4})(.{4})(.{4})(.{12})/','$1-$2-$3-$4-$5', $idem),0,36);

    $pdo->beginTransaction();
    try {
        // 1) Bloquea la fila del producto y valida
        $prod = $pdo->prepare("SELECT PriceCredits, ItemHex, Active FROM WebshopProducts WHERE ProductId = ?");
        $prod->execute([$productId]);
        $p = $prod->fetch(PDO::FETCH_ASSOC);
        if (!$p || !$p['Active']) throw new RuntimeException('Producto no disponible');

        // 2) Debita el saldo SOLO si hay crédito suficiente (chequeo en la cláusula WHERE)
        $upd = $pdo->prepare(
          "UPDATE MEMB_CREDITS SET Credits = Credits - ?
           WHERE AccountId = ? AND Credits >= ?");
        $upd->execute([$p['PriceCredits'], $accountId, $p['PriceCredits']]);
        if ($upd->rowCount() === 0) throw new RuntimeException('Saldo insuficiente');

        // 3) Registra el pedido (UNIQUE(IdemToken) impide duplicidad)
        $ins = $pdo->prepare(
          "INSERT INTO WebshopOrders (AccountId, CharName, ProductId, PriceCredits, IdemToken, Status, IpAddress)
           VALUES (?,?,?,?,?, 'QUEUED', ?)");
        $ins->execute([$accountId,$charName,$productId,$p['PriceCredits'],$idem,$_SERVER['REMOTE_ADDR'] ?? null]);
        $orderId = (int)$pdo->lastInsertId();

        // 4) Encola la entrega
        $q = $pdo->prepare(
          "INSERT INTO WebshopDeliveryQueue (OrderId, CharName, ItemHex) VALUES (?,?,?)");
        $q->execute([$orderId, $charName, $p['ItemHex']]);

        $pdo->commit();
        return ['ok'=>true, 'orderId'=>$orderId];
    } catch (\Throwable $e) {
        $pdo->rollBack();
        return ['ok'=>false, 'error'=>$e->getMessage()];
    }
}

Fíjate en dos detalles de seguridad fundamentales:

  • El débito se hace con WHERE ... AND Credits >= ?. Así, incluso con dos solicitudes simultáneas (race condition), la base de datos garantiza que solo una consigue debitar cuando el saldo es justo — la segunda devuelve rowCount() === 0.
  • Todas las queries usan prepared statements. Nunca concatenes $accountId o $productId directamente en el SQL.

Paso 4 — El worker de entrega (con chequeo de offline)

El worker es un script ejecutado periódicamente (Task Scheduler en Windows, cron en Linux) que procesa la cola. Solo entrega si el personaje está offline.

<?php
// worker_entrega.php — ejecútalo cada 1 minuto
require 'db.php';

$pendentes = $pdo->query(
  "SELECT TOP 50 QueueId, OrderId, CharName, ItemHex
   FROM WebshopDeliveryQueue WHERE Delivered = 0 AND Attempts < 5")->fetchAll(PDO::FETCH_ASSOC);

foreach ($pendentes as $row) {
    // 1) ¿Está online? (la tabla MEMB_STAT/ConnectStat varía por versión)
    $st = $pdo->prepare("SELECT ConnectStat FROM MEMB_STAT ms
        JOIN Character c ON c.AccountID = ms.memb___id WHERE c.Name = ?");
    $st->execute([$row['CharName']]);
    $online = (int)($st->fetchColumn() ?: 0);
    if ($online === 1) { continue; } // lo deja en la cola hasta que se desconecte

    $pdo->beginTransaction();
    try {
        // 2) Escribe el ítem en un slot libre del baúl (warehouse)
        //    La tabla y los offsets del baúl varían por versión.
        $ok = inserirItemNoBau($pdo, $row['CharName'], $row['ItemHex']);
        if (!$ok) throw new RuntimeException('Baúl lleno o slot inválido');

        // 3) Marca como entregado
        $pdo->prepare("UPDATE WebshopDeliveryQueue SET Delivered=1 WHERE QueueId=?")
            ->execute([$row['QueueId']]);
        $pdo->prepare("UPDATE WebshopOrders SET Status='DELIVERED' WHERE OrderId=?")
            ->execute([$row['OrderId']]);
        $pdo->commit();
    } catch (\Throwable $e) {
        $pdo->rollBack();
        $pdo->prepare("UPDATE WebshopDeliveryQueue SET Attempts=Attempts+1 WHERE QueueId=?")
            ->execute([$row['QueueId']]);
    }
}

La función inserirItemNoBau localiza un slot vacío en el baúl del personaje y graba el blob del ítem en la posición correcta. Como el layout del baúl (tabla warehouse, columna Items, tamaño de cada slot) varía por versión, esa parte necesita adaptarse a tu Season. El principio, sin embargo, es universal: encontrar espacio libre, grabar el hex en el offset del slot y no exceder la capacidad.

Paso 5 — Front-end y experiencia del jugador

En el front-end, muestra el catálogo por categorías, el saldo actual y un botón de compra con confirmación. Un punto de seguridad que muchos olvidan: deshabilita el botón después del clic para evitar el reenvío, y valida todo de nuevo en el servidor — el front-end nunca es la fuente de verdad.

// Bloquea el doble clic en el botón de compra
const btn = document.querySelector('#comprar');
btn.addEventListener('click', async (e) => {
  btn.disabled = true;
  btn.textContent = 'Procesando...';
  try {
    const r = await fetch('/loja/comprar', {
      method: 'POST',
      headers: {'Content-Type':'application/json','X-CSRF-Token': window.csrf},
      body: JSON.stringify({ productId: btn.dataset.pid, char: window.charName })
    });
    const data = await r.json();
    alert(data.ok ? '¡Ítem en la cola! Desconéctate para recibirlo en el baúl.' : 'Error: ' + data.error);
  } finally {
    btn.disabled = false;
    btn.textContent = 'Comprar';
  }
});

Avisa siempre al jugador con claridad: "Desconecta el personaje para recibir el ítem en el baúl". Esa sola frase corta la mayoría de los tickets de "compré y no llegó".

Paso 6 — Precios y balanceo

La parte técnica es la mitad del trabajo; la otra mitad es lo que vendes. Vender poder puro (ítems full excellent, sets +15) transforma el servidor en pay-to-win y aleja a la base gratuita. Estrategias saludables:

  • Cosméticos y conveniencia: cambio de nombre, cambio de clase, reset de stats, alas visuales, transformaciones.
  • Ítems de progresión moderada: kits iniciales, joyas en paquete, buffs temporales de XP.
  • Servicios: slot extra de baúl, expansión de guild, alquiler de VIP.

Define el precio en créditos con base en cuánto tiempo de juego representa ese ítem. Un ítem que lleva 10 horas de farm no puede costar el equivalente a 1 dólar en créditos, o el farm pierde sentido.

Errores comunes y soluciones

SíntomaCausa probableSolución
El ítem comprado no aparece en el baúlEl personaje estaba online en la entregaConfirma el chequeo de offline en el worker; entrega solo con ConnectStat=0
Baúl corrupto / se traba al abrirHex del ítem con offset equivocado de la SeasonRehaz el hex con la tabla correcta de tu versión en un entorno de prueba
El jugador compró 2x con 1 clicFalta de idempotenciaImplementa el IdemToken UNIQUE y deshabilita el botón en el front
El saldo quedó negativoDébito sin chequeo en el WHEREUsa UPDATE ... WHERE Credits >= precio y verifica rowCount
Ítem duplicado tras una falla de redRetry sin tokenReenvía siempre el mismo IdemToken del intento original
Tienda lenta / se traba bajo cargaQueries sin índice y sin transacciónIndexa CharName/Status y envuelve la compra en una transacción
Precio alterado por el clienteConfianza en el valor que viene del frontLee siempre el precio de la base de datos por el ProductId, nunca del request

Lista de verificación de lanzamiento

  • Tablas de la tienda creadas y separadas de las tablas del juego
  • Formato de ítem validado en tu Season con cuenta de prueba
  • La compra corre dentro de una transacción atómica (débito + pedido + cola)
  • IdemToken UNIQUE activo contra compras duplicadas
  • Débito de crédito con WHERE Credits >= precio
  • El worker de entrega solo procesa personajes offline
  • Todas las queries usando prepared statements
  • El botón de compra se deshabilita tras el clic y valida CSRF
  • Precios siempre leídos de la base de datos, nunca del front-end
  • Índices en CharName, Status y Delivered para el rendimiento
  • Aviso claro "desconéctate para recibir" visible en la tienda
  • Logs de auditoría (quién compró qué, cuándo y desde qué IP)
  • Probado el escenario de baúl lleno (retry sin perder ítem ni crédito)

Con esta arquitectura tienes una webshop confiable, auditable y resistente a los fraudes clásicos de MU Online. Empieza pequeño, con pocos productos de conveniencia, valida la entrega exhaustivamente en pruebas y solo entonces ábrela a los jugadores.

Preguntas frecuentes

¿La webshop entrega ítems con el jugador conectado?

Lo ideal es entregar solo con el personaje desconectado. Si el jugador está logueado, el servidor mantiene los datos del inventario en memoria y la escritura directa en la base de datos será sobrescrita al desconectar. Encola la entrega y procésala cuando el estado sea offline.

¿Necesito modificar el código fuente del GameServer para tener webshop?

No necesariamente. La mayoría de las webshops entrega ítems escribiendo directamente en la tabla de baúl/warehouse de la base de datos. Solo hace falta tocar el source si quieres una entrega en tiempo real vía socket, algo más avanzado.

¿Cómo genero el hexadecimal de un ítem correctamente?

El formato del ítem varía por versión (Season 6 usa 32 bytes; las seasons modernas usan blobs más grandes). Usa una tabla de referencia de tu Season y un builder que arme índice, nivel, opciones, luck, skill, excellent y ancient en los offsets correctos.

¿La webshop da ventaja pay-to-win y aleja jugadores?

Depende de lo que vendas. Cosméticos, alas visuales, cambio de nombre y servicios de conveniencia suelen ser bien aceptados. Vender ítems con opciones full excellent rompe el balanceo y vacía el servidor a mediano plazo.

¿Cómo evito que un jugador compre el mismo ítem dos veces por doble clic?

Usa un bloqueo de idempotencia: genera un token único por intento de compra, debita el saldo dentro de una transacción SQL y registra el pedido antes de encolar la entrega. Bloquea también los clics repetidos en el front-end.

GA
Editor de guías y builds

Gabriel cubre gameplay, builds de clases, PvP y progresión. Prueba cada estrategia en un servidor antes de publicar.

Sigue leyendo

Artículos relacionados