Como criar uma loja de itens web (webshop) para MU Online
Guia completo para construir uma webshop segura para seu servidor de MU Online, integrando saldo de créditos, entrega automática de itens no baú e proteção contra fraudes.
Uma webshop (loja de itens web) é um dos recursos que mais diferencia um servidor de MU Online amador de um projeto profissional. Ela permite que o jogador troque créditos — comprados via doação ou ganhos em eventos — por itens, asas, kits e serviços diretamente pelo navegador, sem depender de um GM
Uma webshop (loja de itens web) é um dos recursos que mais diferencia um servidor de MU Online amador de um projeto profissional. Ela permite que o jogador troque créditos — comprados via doação ou ganhos em eventos — por itens, asas, kits e serviços diretamente pelo navegador, sem depender de um GM online. Bem construída, ela roda 24 horas, reduz o trabalho manual da equipe e vira uma das principais fontes de receita e retenção do servidor. Mal construída, ela vira um buraco de segurança que dá itens infinitos, corrompe personagens e permite fraude de saldo.
Este tutorial é avançado porque uma webshop toca nas três partes mais sensíveis do seu ecossistema ao mesmo tempo: o banco de dados de contas/personagens, o formato binário dos itens da sua Season e a camada web exposta à internet. Vamos montar uma arquitetura correta do zero, com entrega segura, transações atômicas e proteção contra as fraudes clássicas. Se você ainda não tem o servidor no ar, comece pelo guia base em como criar um servidor de MU Online e volte aqui depois.
Pré-requisitos
Antes de escrever uma linha de código da loja, garanta que você tem o ambiente e o conhecimento mínimos:
- Servidor de MU Online funcional (GameServer, ConnectServer e DataServer no ar).
- Banco de dados acessível pela aplicação web — SQL Server (MuOnline/MuOnline DB) na maioria das Seasons, ou MySQL/MariaDB em distros modernas.
- Website já publicado com PHP 8.1+ (ou a stack que você usa) e conexão ao banco funcionando.
- Sistema de contas com login funcional no site e um campo de saldo/créditos (WCoin, Credits, Cash ou coluna equivalente).
- Conhecimento do formato de item da sua Season — índice, tipo, nível, opções, excellent, ancient, luck e skill.
- Ambiente de testes (uma conta e um personagem descartáveis) para validar entregas antes de liberar para os jogadores.
> Aviso de segurança: nunca desenvolva a webshop direto no banco de produção. Um erro num INSERT de item pode corromper personagens reais. Trabalhe sempre com um dump de teste primeiro.
Como uma webshop realmente entrega itens
Existe uma confusão comum: muita gente acha que a webshop "fala com o servidor". Na prática, a esmagadora maioria das lojas web de MU não conversa diretamente com o GameServer — ela escreve o item na tabela de baú (warehouse) do banco de dados, e o GameServer lê essa tabela quando o jogador abre o baú.
Isso tem uma consequência crítica: o item só aparece de forma confiável quando o personagem está offline. Enquanto o jogador está logado, o servidor mantém uma cópia do inventário e do baú em memória. Se você escrever no banco nesse momento, o servidor sobrescreve tudo ao salvar no logout, e o item comprado some — gerando ticket, revolta e pedido de reembolso.
Por isso, a arquitetura correta é assíncrona:
- O jogador compra na web; o saldo é debitado e um pedido é registrado.
- A entrega vai para uma fila (tabela
webshop_delivery_queue). - Um processo verifica se o personagem está offline.
- Quando offline, o item é escrito no baú dentro de uma transação e o pedido é marcado como entregue.
| Modelo de entrega | Como funciona | Prós | Contras |
|---|---|---|---|
| Escrita direta no baú (offline) | Loja insere item na tabela warehouse | Simples, sem mexer no source | Só funciona com jogador deslogado |
| Fila + worker | Pedido enfileirado e processado por serviço | Seguro, auditável, escalável | Exige um processo agendado rodando |
| Socket em tempo real | Web envia comando ao GameServer via source customizado | Entrega instantânea online | Requer editar e recompilar o source |
| Cupom/resgate in-game | Web gera código, jogador resgata via NPC/comando | Zero escrita direta no banco | Fricção para o jogador |
Para 90% dos servidores, o modelo fila + worker é o certo. É o que vamos montar.
Passo 1 — Modelar as tabelas da loja
Crie tabelas próprias da webshop, separadas das tabelas do jogo. Nunca reutilize tabelas do MU para controle da loja. Exemplo em SQL Server (ajuste tipos para MySQL se for o seu caso):
-- Catálogo de produtos da loja
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 do item para a Season
DurabilityQty INT NOT NULL DEFAULT 1,
Active BIT NOT NULL DEFAULT 1,
CreatedAt DATETIME NOT NULL DEFAULT GETDATE()
);
-- Pedidos (auditoria e idempotência)
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()
);
-- Fila 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()
);
A coluna IdemToken é o coração da proteção contra duplicidade: cada tentativa de compra gera um UUID; o índice UNIQUE garante que a mesma tentativa nunca vire dois pedidos, mesmo que o jogador dê duplo clique ou a conexão caia e ele reenvie.
Passo 2 — Entender o formato do item da sua Season
Este é o ponto que mais quebra webshop. Cada item no MU é representado por uma sequência binária com campos em offsets fixos. O formato varia por versão — abaixo vai um EXEMPLO didático baseado no layout clássico de 32 bytes (estilo Season 6); confirme os offsets na documentação da sua distro antes de usar em produção.
| Campo (exemplo) | Bytes aprox. | Significado |
|---|---|---|
| Index/Section | 0-1 | Qual item (ex.: 0x0007 = espada X) |
| Level | 1 | Nível de refino +0 a +15 |
| Options (dur) | 2 | Opção adicional / durabilidade |
| Excellent flags | 1 | Bitmask das opções excellent |
| Ancient/Set | 1 | Item ancient e set bônus |
| Luck/Skill | bits | Flags de luck e skill |
| Serial | 4-8 | Serial único do item |
Um builder em PHP que monta o hex a partir de parâmetros legíveis torna o cadastro de produtos muito menos propenso a erro:
<?php
// EXEMPLO simplificado — os offsets REAIS variam por versão/Season.
function buildItemHex(array $it): string {
$index = $it['index'] ?? 0; // código do item
$level = $it['level'] ?? 0; // 0..15
$skill = !empty($it['skill']) ? 1 : 0;
$luck = !empty($it['luck']) ? 1 : 0;
$option = $it['option'] ?? 0; // 0..3 (opção +4/+8/...)
$exc = $it['exc'] ?? 0; // bitmask excellent (0..63)
// Byte de nível: normalmente level << 3 combinado com skill bit
$lvlByte = (($level & 0x0F) << 3) | ($skill << 7);
// Byte de opção: option nos bits baixos + 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: o código acima é ilustrativo. Não copie os offsets cegamente — pegue o layout correto da sua Season (a documentação da distro ou o próprio source ItemManager/ItemConvert). Um offset errado gera item inválido que trava o baú do jogador.
Passo 3 — O fluxo de compra com transação atômica
A compra precisa ser atômica: debitar o saldo e registrar o pedido têm que acontecer juntos ou não acontecer. Nunca debite crédito num comando e insira o pedido em outro sem transação — uma falha no meio deixa o jogador sem crédito e sem item.
<?php
function comprarItem(PDO $pdo, string $accountId, string $charName, int $productId): array {
$idem = bin2hex(random_bytes(16)); // token de idempotência
$idem = substr(preg_replace('/(.{8})(.{4})(.{4})(.{4})(.{12})/','$1-$2-$3-$4-$5', $idem),0,36);
$pdo->beginTransaction();
try {
// 1) Trava a linha do produto e 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('Produto indisponível');
// 2) Debita o saldo SOMENTE se houver crédito suficiente (checagem na 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 o pedido (UNIQUE(IdemToken) impede duplicidade)
$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) Enfileira a 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()];
}
}
Repare em dois detalhes de segurança fundamentais:
- O débito é feito com
WHERE ... AND Credits >= ?. Assim, mesmo com duas requisições simultâneas (race condition), o banco garante que só uma consegue debitar quando o saldo é apertado — a segunda retornarowCount() === 0. - Todas as queries usam prepared statements. Nunca concatene
$accountIdou$productIddireto no SQL.
Passo 4 — O worker de entrega (com checagem de offline)
O worker é um script executado periodicamente (Task Scheduler no Windows, cron no Linux) que processa a fila. Ele só entrega se o personagem estiver offline.
<?php
// worker_entrega.php — rode a 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? (tabela MEMB_STAT/ConnectStat varia por versão)
$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; } // deixa na fila até deslogar
$pdo->beginTransaction();
try {
// 2) Escreve o item num slot livre do baú (warehouse)
// A tabela e os offsets do baú variam por versão.
$ok = inserirItemNoBau($pdo, $row['CharName'], $row['ItemHex']);
if (!$ok) throw new RuntimeException('Baú cheio ou slot inválido');
// 3) Marca como entregue
$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']]);
}
}
A função inserirItemNoBau localiza um slot vazio no baú do personagem e grava o blob do item na posição correta. Como o layout do baú (tabela warehouse, coluna Items, tamanho de cada slot) varia por versão, essa parte precisa ser adaptada à sua Season. O princípio, porém, é universal: achar espaço livre, gravar o hex no offset do slot e não estourar a capacidade.
Passo 5 — Front-end e experiência do jogador
No front-end, exiba o catálogo por categorias, o saldo atual e um botão de compra com confirmação. Um ponto de segurança que muitos esquecem: desabilite o botão após o clique para evitar reenvio, e valide tudo de novo no servidor — o front-end nunca é a fonte de verdade.
// Bloqueia duplo clique no botão de compra
const btn = document.querySelector('#comprar');
btn.addEventListener('click', async (e) => {
btn.disabled = true;
btn.textContent = 'Processando...';
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 ? 'Item na fila! Deslogue para receber no baú.' : 'Erro: ' + data.error);
} finally {
btn.disabled = false;
btn.textContent = 'Comprar';
}
});
Sempre avise o jogador claramente: "Deslogue o personagem para receber o item no baú". Essa única frase corta a maioria dos tickets de "comprei e não veio".
Passo 6 — Precificação e balanceamento
A parte técnica é metade do trabalho; a outra metade é o que você vende. Vender poder puro (itens full excellent, sets +15) transforma o servidor em pay-to-win e afasta a base gratuita. Estratégias saudáveis:
- Cosméticos e conveniência: mudança de nome, mudança de classe, reset de stats, asas visuais, transformações.
- Itens de progressão moderada: kits iniciantes, joias em pacote, buffs temporários de XP.
- Serviços: slot extra de baú, expansão de guild, aluguel de VIP.
Defina o preço em créditos com base em quanto tempo de jogo aquele item representa. Um item que leva 10 horas de farm não pode custar o equivalente a R$ 1 em créditos, ou o farm perde o sentido.
Erros comuns e soluções
| Sintoma | Causa provável | Solução |
|---|---|---|
| Item comprado não aparece no baú | Personagem estava online na entrega | Confirme a checagem de offline no worker; entregue só com ConnectStat=0 |
| Baú corrompido / trava ao abrir | Hex do item com offset errado da Season | Refaça o hex com a tabela correta da sua versão em ambiente de teste |
| Jogador comprou 2x com 1 clique | Falta de idempotência | Implemente o IdemToken UNIQUE e desabilite o botão no front |
| Saldo ficou negativo | Débito sem checagem no WHERE | Use UPDATE ... WHERE Credits >= preco e cheque rowCount |
| Item duplicado após falha de rede | Retry sem token | Reenvie sempre o mesmo IdemToken da tentativa original |
| Loja lenta / trava sob carga | Queries sem índice e sem transação | Indexe CharName/Status e envolva compra numa transação |
| Preço alterado pelo cliente | Confiança no valor vindo do front | Sempre leia o preço do banco pelo ProductId, nunca do request |
Checklist de lançamento
- Tabelas da loja criadas e separadas das tabelas do jogo
- Formato de item validado na sua Season com conta de teste
- Compra roda dentro de transação atômica (débito + pedido + fila)
- IdemToken UNIQUE ativo contra compras duplicadas
- Débito de crédito com
WHERE Credits >= preco - Worker de entrega só processa personagens offline
- Todas as queries usando prepared statements
- Botão de compra desabilita após clique e valida CSRF
- Preços sempre lidos do banco, nunca do front-end
- Índices em CharName, Status e Delivered para performance
- Aviso claro "deslogue para receber" visível na loja
- Logs de auditoria (quem comprou o quê, quando e de qual IP)
- Testado o cenário de baú cheio (retry sem perder item nem crédito)
Com essa arquitetura você tem uma webshop confiável, auditável e resistente às fraudes clássicas do MU Online. Comece pequeno, com poucos produtos de conveniência, valide a entrega exaustivamente em teste e só então abra para os jogadores.
Perguntas frequentes
A webshop entrega itens com o jogador online?
O ideal é entregar apenas com o personagem offline. Se o jogador estiver logado, o servidor mantém os dados do inventário em memória e a escrita direta no banco será sobrescrita ao deslogar. Enfileire a entrega e processe quando o status estiver offline.
Preciso mexer no código-fonte do GameServer para ter webshop?
Não necessariamente. A maioria das webshops entrega itens escrevendo diretamente na tabela de baú/warehouse do banco. Só é preciso mexer no source se você quiser uma entrega em tempo real via socket, algo mais avançado.
Como gerar o hexadecimal de um item corretamente?
O formato do item varia por versão (Season 6 usa 32 bytes; seasons modernas usam blobs maiores). Use uma tabela de referência da sua Season e um builder que monte índice, nível, opções, luck, skill, excellent e ancient nos offsets corretos.
Webshop dá vantagem pay-to-win e afasta jogadores?
Depende do que você vende. Cosméticos, asas visuais, mudança de nome e serviços de conveniência costumam ser bem aceitos. Vender itens com opções full excellent quebra o balanceamento e esvazia o servidor a médio prazo.
Como evito que um jogador compre o mesmo item duas vezes por duplo clique?
Use uma trava de idempotência: gere um token único por tentativa de compra, debite o saldo dentro de uma transação SQL e registre o pedido antes de enfileirar a entrega. Bloqueie cliques repetidos no front-end também.