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.
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_owneren la baseMuOnline. - Sitio en PHP 7.4+ alojado y conectado a la base de datos, idealmente vía PDO con el driver
sqlsrvodblib. - 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:
| Campo | Función | Observación |
|---|---|---|
cLevel | Nivel actual del personaje | Puesto a 1 en el reset |
Resets o ResetCount | Contador acumulado de resets | Varía según la versión |
LevelUpPoint | Puntos de atributo disponibles | Algunos emus usan AddPoint |
Strength/Dexterity/Vitality/Energy | Atributos | Restaurados al valor base de la clase |
Leadership | Comando (solo Dark Lord) | Poner a cero o preservar según la regla |
Experience | EXP actual | Puesta a cero en el reset |
MapNumber/MapPosX/MapPosY | Posición | Mover a Lorencia/spawn |
ConnectStat | Estado en línea | 0 = 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
ResetLogbuscando cuentas con muchos resets en un intervalo corto.
Errores comunes y soluciones
| Error | Causa probable | Solución |
|---|---|---|
| El reset desaparece cuando el jugador entra al juego | El personaje estaba en línea en el momento del reset | Bloquear el reset con ConnectStat <> 0; pedir logout |
| Los puntos no aparecen en el personaje | Campo equivocado (LevelUpPoint vs AddPoint) | Confirmar la columna con INFORMATION_SCHEMA |
| Reset doble en clics rápidos | Falta de transacción/lock y token de un solo uso | UPDLOCK en la procedure + CSRF de un solo uso |
| Error "conversion failed" en PHP | Tipo de parámetro incompatible (BIGINT vs INT) | Alinear los tipos entre PDO y la procedure |
| Se resetea el personaje de otra cuenta | No valida la propiedad | Comparar AccountID en la procedure y en PHP |
| SQL Injection en el nombre | Concatenación de string en la consulta | Usar siempre prepared statements/parámetros |
Lista de verificación de lanzamiento
- Backup de la base
MuOnlinerealizado antes de cualquier prueba. - Nombres de columnas confirmados vía INFORMATION_SCHEMA en tu versión.
- Stored procedure
WZ_ResetViaSitecreada y probada en SSMS. - Tabla
ResetLogcreada 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.