El mayor portal de MU Online de Brasil — desde 2003
Tutorial Intermedio Web

Cómo crear un sistema de reset vía web en MU Online

Implementa un sistema de reset vía web en MU Online con validación segura en PHP, stored procedures en SQL Server y protección contra fraudes y resets duplicados.

BR Bruno · Actualizado el 10 jul 2025 · ⏱ 18 min de lectura
Respuesta rápida

Ofrecer el reset directamente por el sitio es una de las funcionalidades más solicitadas por los jugadores de servidores privados de MU Online. En vez de escribir /reset dentro del juego o depender de un NPC, el jugador accede al panel de cuenta, elige el personaje y confirma el reset en pocos clics

Ofrecer el reset directamente por el sitio es una de las funcionalidades más solicitadas por los jugadores de servidores privados de MU Online. En vez de escribir /reset dentro del juego o depender de un NPC, el jugador accede al panel de cuenta, elige el personaje y confirma el reset en pocos clics. Parece simple, pero detrás de esa comodidad existe una cadena de validaciones críticas: nivel mínimo, saldo, estado en línea, límites de reset y — sobre todo — seguridad contra la manipulación. Un sistema de reset mal hecho es la puerta de entrada más común para exploits que arruinan la economía de un servidor.

Este tutorial muestra cómo construir un sistema de reset vía web robusto, con la lógica pesada dentro de una stored procedure en SQL Server y una capa PHP fina, segura y auditable. Los nombres de campos y tablas presentados son un EJEMPLO común de Season 6; la estructura exacta varía según la versión (Season 6, Season 9, seasons modernas y emuladores como IGCN, MuEmu, X-Team tienen diferencias). Confirma siempre el esquema de tu base de datos antes de aplicar. Si aún no tienes la base del servidor y del sitio funcionando, comienza por la guía de cómo crear un servidor de MU Online y vuelve aquí después.

Requisitos previos

Antes de empezar, asegúrate de tener el entorno listo:

  • Servidor de MU Online funcional (GameServer + ConnectServer + base de datos) en funcionamiento.
  • SQL Server (2008, 2014, 2017 o 2019) con acceso vía SQL Server Management Studio (SSMS) y permiso db_owner en la base MuOnline.
  • Sitio en PHP 7.4+ alojado y conectado a la base de datos, idealmente vía PDO con el driver sqlsrv o dblib.
  • Sistema de login/sesión del panel de cuenta ya funcionando (el jugador necesita estar autenticado para hacer reset).
  • Backup reciente de la base de datos. Nunca pruebes stored procedures de UPDATE masivo en producción sin backup.

Paso 1 — Entender la estructura de reset en la base de datos

El reset es, en esencia, un UPDATE en la tabla Character que pone el nivel a cero, restaura los atributos base, incrementa el contador de resets y otorga puntos. El primer paso es descubrir exactamente qué columnas usa tu versión. Ejecuta en SSMS:

SELECT COLUMN_NAME, DATA_TYPE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'Character'
  AND (COLUMN_NAME LIKE '%Reset%'
    OR COLUMN_NAME LIKE '%Level%'
    OR COLUMN_NAME LIKE '%Point%'
    OR COLUMN_NAME IN ('Strength','Dexterity','Vitality','Energy','Leadership'));

Los campos que interesan en un EJEMPLO típico de Season 6:

CampoFunciónObservación
cLevelNivel actual del personajePuesto a 1 en el reset
Resets o ResetCountContador acumulado de resetsVaría según la versión
LevelUpPointPuntos de atributo disponiblesAlgunos emus usan AddPoint
Strength/Dexterity/Vitality/EnergyAtributosRestaurados al valor base de la clase
LeadershipComando (solo Dark Lord)Poner a cero o preservar según la regla
ExperienceEXP actualPuesta a cero en el reset
MapNumber/MapPosX/MapPosYPosiciónMover a Lorencia/spawn
ConnectStatEstado en línea0 = offline; usado para bloquear el reset en línea

Anota los nombres correctos — todo el resto del tutorial se apoya en ellos.

Paso 2 — Crear la stored procedure de reset

La regla de oro: la lógica de reset vive en la base de datos, no en PHP. Poner todo dentro de una stored procedure con transacción garantiza la atomicidad (o todo ocurre, o nada ocurre) y reduce drásticamente la superficie de ataque. PHP solo llama a la procedure e interpreta el retorno.

USE MuOnline;
GO

IF OBJECT_ID('dbo.WZ_ResetViaSite', 'P') IS NOT NULL
    DROP PROCEDURE dbo.WZ_ResetViaSite;
GO

CREATE PROCEDURE dbo.WZ_ResetViaSite
    @AccountID   VARCHAR(10),
    @CharName    VARCHAR(10),
    @MinLevel    INT = 400,
    @MaxReset    INT = 0,      -- 0 = sin límite
    @ZenCost     BIGINT = 0
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;

    DECLARE @Level INT, @Reset INT, @Money BIGINT, @Online TINYINT, @Owner VARCHAR(10);

    BEGIN TRANSACTION;

    -- Bloquea la fila del personaje durante la lectura para evitar la condición de carrera
    SELECT @Level = cLevel, @Reset = Resets, @Money = Money, @Online = ConnectStat
    FROM dbo.Character WITH (UPDLOCK, ROWLOCK)
    WHERE Name = @CharName;

    -- Confirma que el personaje pertenece a la cuenta autenticada
    SELECT @Owner = AccountID
    FROM dbo.Character
    WHERE Name = @CharName;

    IF @Owner IS NULL OR @Owner <> @AccountID
    BEGIN
        ROLLBACK TRANSACTION;
        SELECT -10 AS Result, 'El personaje no pertenece a esta cuenta.' AS Message;
        RETURN;
    END

    IF @Online <> 0
    BEGIN
        ROLLBACK TRANSACTION;
        SELECT -1 AS Result, 'Sal del juego antes de hacer reset.' AS Message;
        RETURN;
    END

    IF @Level < @MinLevel
    BEGIN
        ROLLBACK TRANSACTION;
        SELECT -2 AS Result, 'Nivel insuficiente para el reset.' AS Message;
        RETURN;
    END

    IF @MaxReset > 0 AND @Reset >= @MaxReset
    BEGIN
        ROLLBACK TRANSACTION;
        SELECT -3 AS Result, 'Límite de resets alcanzado.' AS Message;
        RETURN;
    END

    IF @ZenCost > 0 AND @Money < @ZenCost
    BEGIN
        ROLLBACK TRANSACTION;
        SELECT -4 AS Result, 'Zen insuficiente.' AS Message;
        RETURN;
    END

    -- Ejecuta el reset
    UPDATE dbo.Character
    SET cLevel       = 1,
        Resets       = Resets + 1,
        LevelUpPoint = LevelUpPoint + 500,   -- EJEMPLO: puntos por reset
        Experience   = 0,
        Strength     = 20,
        Dexterity    = 20,
        Vitality     = 20,
        Energy       = 20,
        Money        = Money - @ZenCost,
        MapNumber    = 0,     -- Lorencia
        MapPosX      = 125,
        MapPosY      = 125
    WHERE Name = @CharName;

    -- Log de auditoría
    INSERT INTO dbo.ResetLog (AccountID, CharName, ResetNumber, ResetDate, Origem)
    VALUES (@AccountID, @CharName, @Reset + 1, GETDATE(), 'SITE');

    COMMIT TRANSACTION;

    SELECT 1 AS Result, 'Reset realizado con éxito.' AS Message;
END
GO

Crea también la tabla de log, esencial para el soporte y la detección de abuso:

CREATE TABLE dbo.ResetLog (
    ID          INT IDENTITY(1,1) PRIMARY KEY,
    AccountID   VARCHAR(10),
    CharName    VARCHAR(10),
    ResetNumber INT,
    ResetDate   DATETIME,
    Origem      VARCHAR(10)
);

> Consejo de balance: la fórmula de puntos por reset y el costo en Zen deben calibrarse junto con las rates de EXP. Los servidores de alta rate suelen dar de 300 a 700 puntos por reset; los low rate rara vez pasan de 100.

Paso 3 — Configurar la conexión PDO en PHP

Centraliza la conexión en un único archivo para reutilizarla en todo el sitio. Nunca dejes credenciales en el repositorio público — usa variables de entorno o un archivo config.php fuera de la raíz web.

<?php
// db.php
function getDB(): PDO {
    $host = getenv('MU_DB_HOST') ?: '127.0.0.1';
    $db   = 'MuOnline';
    $user = getenv('MU_DB_USER');
    $pass = getenv('MU_DB_PASS');

    $dsn = "sqlsrv:Server={$host};Database={$db}";
    $options = [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ];
    return new PDO($dsn, $user, $pass, $options);
}

Paso 4 — Construir el formulario de reset

El formulario debe listar solo los personajes de la cuenta logueada e incluir un token CSRF. Nunca confíes en campos ocultos con el nombre del personaje sin revalidar la propiedad en el servidor.

<?php
session_start();
require 'db.php';

if (empty($_SESSION['account_id'])) {
    header('Location: /login.php');
    exit;
}

// Genera token CSRF de un solo uso
if (empty($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
}

$pdo = getDB();
$stmt = $pdo->prepare(
    'SELECT Name, cLevel, Resets FROM Character WHERE AccountID = ? ORDER BY Name'
);
$stmt->execute([$_SESSION['account_id']]);
$chars = $stmt->fetchAll();
?>
<form method="post" action="/reset_processar.php">
  <input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
  <select name="char" required>
    <?php foreach ($chars as $c): ?>
      <option value="<?= htmlspecialchars($c['Name']) ?>">
        <?= htmlspecialchars($c['Name']) ?> — Nivel <?= (int)$c['cLevel'] ?> — Resets: <?= (int)$c['Resets'] ?>
      </option>
    <?php endforeach; ?>
  </select>
  <button type="submit">Resetear personaje</button>
</form>

Paso 5 — Procesar el reset con seguridad

Este es el corazón del sistema. El script valida el token CSRF, llama a la stored procedure con parámetros vinculados e interpreta el código de retorno. Toda la validación de reglas de negocio ya ocurrió en la base de datos — aquí solo orquestamos y mostramos el resultado.

<?php
session_start();
require 'db.php';

if (empty($_SESSION['account_id'])) { http_response_code(403); exit('No autenticado.'); }

// Valida CSRF
if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) {
    http_response_code(400); exit('Token inválido. Recarga la página.');
}
unset($_SESSION['csrf']); // token de un solo uso

$char = trim($_POST['char'] ?? '');
if ($char === '' || strlen($char) > 10) { exit('Personaje inválido.'); }

$pdo = getDB();
$sql = 'EXEC dbo.WZ_ResetViaSite
            @AccountID = :acc,
            @CharName  = :char,
            @MinLevel  = :minlv,
            @MaxReset  = :maxrst,
            @ZenCost   = :zen';
$stmt = $pdo->prepare($sql);
$stmt->execute([
    ':acc'    => $_SESSION['account_id'],
    ':char'   => $char,
    ':minlv'  => 400,
    ':maxrst' => 0,
    ':zen'    => 5000000,   // EJEMPLO de costo
]);
$res = $stmt->fetch();

if (($res['Result'] ?? -99) == 1) {
    echo 'Éxito: ' . htmlspecialchars($res['Message']);
} else {
    echo 'Fallo: ' . htmlspecialchars($res['Message'] ?? 'Error desconocido.');
}

Paso 6 — Bloquear el reset de un personaje en línea

El punto más delicado del reset vía web es que el personaje esté dentro del juego. Mientras está en línea, el GameServer mantiene los datos en memoria y, al guardar (auto-guardado periódico o logout), sobrescribe lo que el sitio alteró — el jugador perdería el reset o, peor, ganaría puntos y volvería al nivel antiguo. Por eso la procedure valida ConnectStat <> 0. En algunas versiones el campo es ConnectStat en la tabla MEMB_STAT (asociada a la cuenta) y no en Character; ajústalo según tu build. El enfoque más seguro es combinar las dos verificaciones y, si es posible, integrar con una flag de "kick" para forzar la salida antes del reset.

Paso 7 — Añadir límites y protección contra el abuso

Además del token CSRF, implementa:

  • Rate limiting: como máximo 1 reset cada X segundos por cuenta, controlado por un timestamp en sesión o en una columna LastResetSite.
  • Confirmación doble: un paso de "¿estás seguro?" reduce los resets accidentales y los clics automatizados.
  • Auditoría: consulta periódicamente la ResetLog buscando cuentas con muchos resets en un intervalo corto.

Errores comunes y soluciones

ErrorCausa probableSolución
El reset desaparece cuando el jugador entra al juegoEl personaje estaba en línea en el momento del resetBloquear el reset con ConnectStat <> 0; pedir logout
Los puntos no aparecen en el personajeCampo equivocado (LevelUpPoint vs AddPoint)Confirmar la columna con INFORMATION_SCHEMA
Reset doble en clics rápidosFalta de transacción/lock y token de un solo usoUPDLOCK en la procedure + CSRF de un solo uso
Error "conversion failed" en PHPTipo de parámetro incompatible (BIGINT vs INT)Alinear los tipos entre PDO y la procedure
Se resetea el personaje de otra cuentaNo valida la propiedadComparar AccountID en la procedure y en PHP
SQL Injection en el nombreConcatenación de string en la consultaUsar siempre prepared statements/parámetros

Lista de verificación de lanzamiento

  • Backup de la base MuOnline realizado antes de cualquier prueba.
  • Nombres de columnas confirmados vía INFORMATION_SCHEMA en tu versión.
  • Stored procedure WZ_ResetViaSite creada y probada en SSMS.
  • Tabla ResetLog creada y recibiendo registros.
  • Conexión PDO usando prepared statements, sin concatenación.
  • Token CSRF de un solo uso en el formulario y en el procesador.
  • Bloqueo de reset para personaje en línea validado.
  • Validación de propiedad del personaje (AccountID) en dos capas.
  • Rate limiting por cuenta configurado.
  • Costo en Zen/WCoin y puntos por reset calibrados con las rates.
  • Prueba de punta a punta con cuenta de homologación antes de abrir al público.

Con este diseño, el reset vía web queda rápido para el jugador y tranquilo para ti: la lógica sensible vive protegida en la base de datos, cada operación es atómica y auditable, y la capa PHP permanece pequeña y difícil de explotar. Ajusta las fórmulas de puntos, los límites y los costos según el perfil de tu servidor y prueba siempre en homologación antes de subir a producción.

Preguntas frecuentes

¿El jugador necesita desconectarse para que el reset vía web funcione?

Sí, en la mayoría de las versiones. Mientras el personaje está en línea, el GameServer mantiene los datos en memoria y sobrescribe la base al guardar. Bloquea el reset si el personaje tiene ConnectStat distinto de 0 o pide que el jugador salga del juego antes.

¿Cómo impido que el jugador haga reset dos veces haciendo clic rápido?

Usa una transacción SQL con bloqueo de fila y valida el nivel nuevamente dentro de la stored procedure. En PHP, agrega un token CSRF de un solo uso y un lock por sesión para evitar clics duplicados.

¿Dónde está el contador de resets en la base de datos?

Varía según la versión. En muchas builds de Season 6 el campo es Character.Resets o Character.ResetCount. En otras está en una tabla separada. Verifícalo con la consulta INFORMATION_SCHEMA antes de programar.

¿Puedo cobrar Zen o WCoin por el reset en el sitio?

Sí. Agrega la validación y el decremento del saldo dentro de la misma stored procedure, en la misma transacción del reset, para garantizar la atomicidad. Nunca hagas el débito en una consulta separada de la que pone el nivel a cero.

¿El reset vía web es seguro contra SQL Injection?

Solo si usas PDO con prepared statements o parámetros nombrados en stored procedures. Nunca concatenes el nombre del personaje directamente en la consulta. Trata todo dato que venga del formulario como hostil.

BR
Editor de eventos, mapas e ítems

Bruno es especialista en eventos, mapas, bosses y economía de ítems de MU Online. Documenta cada detalle basándose en el juego real.

Sigue leyendo

Artículos relacionados