Players can now leave their characters auto-farming using the .offlineplay command, even without having the game client open.
🧠 But the most powerful part of this system is that it stores all essential character data in the database, so that after a server restart, the character:
✅ Is automatically loaded from the database
✅ Respawns at the exact same coordinates (x, y, z) and heading
✅ Restores current HP, MP, and CP
✅ Recovers auto-used skill list (Auto Skills)
✅ Recovers full AutoPlay, AutoUse, and AutoSupply configurations
✅ Re-enables Soulshots and Blessed Spiritshots automatically
✅ Continues in offline play mode without any user action
🗄️ What is saved to the database (table character_offline_play) when .offlineplay is activated?
Character ID (charId)
Coordinates: x, y, z, and heading
Current HP, MP, and CP
List of auto-used skills
AutoPlay settings
AutoUse settings (skills and items)
AutoSupply items
Offline mode start timestamp
🛡️ This feature ensures your character keeps farming even after server reboots, making it perfect for continuous, worry-free progression.
package org.l2jmobius.gameserver.data.sql;
import java.sql.*;
import java.util.Arrays;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.l2jmobius.commons.database.DatabaseFactory;
import org.l2jmobius.gameserver.model.World;
import org.l2jmobius.gameserver.model.actor.Player;
import org.l2jmobius.gameserver.taskmanager.AutoPlayTaskManager;
import org.l2jmobius.gameserver.taskmanager.AutoUseTaskManager;
public class OfflinePlayTable
{
private static final Logger LOGGER = Logger.getLogger(OfflinePlayTable.class.getName());
// SQL DEFINITIONS
private static final String CLEAR_OFFLINE_PLAY = "DELETE FROM character_offline_play";
private static final String INSERT_STATE =
"INSERT INTO character_offline_play " +
"(charId,x,y,z,heading,offline_hp,offline_mp,offline_cp," +
"offline_autoskills,offline_autoplay,offline_autouse,offline_autosupply,offline_start) " +
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)";
private static final String LOAD_STATE = "SELECT * FROM character_offline_play";
protected OfflinePlayTable()
{
}
public void storeOfflinePlayers()
{
try (Connection con = DatabaseFactory.getConnection();
PreparedStatement clear = con.prepareStatement(CLEAR_OFFLINE_PLAY);
PreparedStatement insert = con.prepareStatement(INSERT_STATE))
{
// 1) Limpio tabla
clear.execute();
// 2) Inserto cada jugador en offlineplay
for (Player pc : World.getInstance().getPlayers())
{
if (!pc.isOfflinePlay())
{
continue;
}
insert.setInt(1, pc.getObjectId());
insert.setInt(2, pc.getX());
insert.setInt(3, pc.getY());
insert.setInt(4, pc.getZ());
insert.setInt(5, pc.getHeading());
insert.setDouble(6, pc.getCurrentHp());
insert.setDouble(7, pc.getCurrentMp());
insert.setDouble(8, pc.getCurrentCp());
insert.setString(9, serializeAutoSkills(pc));
insert.setString(10, pc.getAutoPlaySettings().serialize());
insert.setString(11, pc.getAutoUseSettings().serialize());
insert.setString(12, serializeAutoSupplyItems(pc));
insert.setLong(13, pc.getOfflineStartTime());
insert.executeUpdate();
}
}
catch (Exception e)
{
LOGGER.log(Level.WARNING, "Error while saving offline play players.", e);
}
}
public void restoreOfflinePlayers()
{
LOGGER.info("OfflinePlayTable: Restoring offlineplay players...");
try (Connection con = DatabaseFactory.getConnection();
Statement stm = con.createStatement();
ResultSet rs = stm.executeQuery(LOAD_STATE))
{
while (rs.next())
{
final int charId = rs.getInt("charId");
final Player pc = Player.load(charId);
if (pc == null)
{
continue;
}
// Posición y stats
pc.setXYZ(rs.getInt("x"), rs.getInt("y"), rs.getInt("z"));
pc.setHeading(rs.getInt("heading"));
pc.setCurrentHp(rs.getDouble("offline_hp"));
pc.setCurrentMp(rs.getDouble("offline_mp"));
pc.setCurrentCp(rs.getDouble("offline_cp"));
// Restaurar configuración
restoreAutoSkills(pc, rs.getString("offline_autoskills"));
pc.getAutoPlaySettings().deserialize(rs.getString("offline_autoplay"));
pc.getAutoUseSettings().deserialize(rs.getString("offline_autouse"), pc);
// Fuerza auto-use de soulshots tras cargar config
// Activar soulshots por defecto al restaurar en modo offlineplay
pc.addAutoSoulShot(91927); // Soulshot
pc.addAutoSoulShot(91930); // Blessed Spiritshot
pc.rechargeShots(true, true, true); // Cargar los shots si tiene en inventario
pc.restoreAutoSettings();
//pc.forceAutoUseShots();
// Reactivar offline play
pc.startOfflinePlay();
pc.spawnMe();
AutoPlayTaskManager.getInstance().startAutoPlay(pc);
LOGGER.info("Restored offline play player: " + pc.getName());
}
}
catch (Exception e)
{
LOGGER.log(Level.WARNING, "Error while restoring offline play players.", e);
}
}
private String serializeAutoSkills(Player pc)
{
return String.join(",",
pc.getAutoUseSettings().getAutoSkills().stream()
.map(Object::toString)
.toArray(String[]::new));
}
private void restoreAutoSkills(Player pc, String csv)
{
if (csv == null || csv.isEmpty())
{
return;
}
Arrays.stream(csv.split(","))
.map(Integer::parseInt)
.forEach(id -> pc.getAutoUseSettings().getAutoSkills().add(id));
}
private String serializeAutoSupplyItems(Player pc)
{
return String.join(",",
pc.getAutoUseSettings().getAutoSupplyItems().stream()
.map(Object::toString)
.toArray(String[]::new));
}
private void restoreAutoSupplyItems(Player pc, String csv)
{
if (csv == null || csv.isEmpty())
{
return;
}
Arrays.stream(csv.split(","))
.map(Integer::parseInt)
.forEach(id -> pc.getAutoUseSettings().getAutoSupplyItems().add(id));
}
public static OfflinePlayTable getInstance()
{
return SingletonHolder.INSTANCE;
}
private static class SingletonHolder
{
protected static final OfflinePlayTable INSTANCE = new OfflinePlayTable();
}
}
SET FOREIGN_KEY_CHECKS=0;
-- ----------------------------
-- Table structure for character_offline_play
-- ----------------------------
CREATE TABLE `character_offline_play` (
`charId` int(11) NOT NULL,
`x` int(11) NOT NULL,
`y` int(11) NOT NULL,
`z` int(11) NOT NULL,
`heading` int(11) NOT NULL,
`offline_hp` double NOT NULL,
`offline_mp` double NOT NULL,
`offline_cp` double NOT NULL,
`offline_autoskills` text DEFAULT NULL,
`offline_autoplay` text DEFAULT NULL,
`offline_autouse` text DEFAULT NULL,
`offline_start` bigint(20) NOT NULL,
`offline_autosupply` text DEFAULT NULL,
PRIMARY KEY (`charId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
-- ----------------------------
-- Records
-- ----------------------------
in AutoUseSettingsHolder.java
// --- Métodos para persistencia ---
public String serialize()
{
String autoSupply = _autoSupplyItems.stream()
.map(id -> {
ItemTemplate item = ItemData.getInstance().getTemplate(id);
if (item != null)
{
ActionType action = item.getDefaultAction();
int type = switch (action)
{
case SOULSHOT -> 16;
case SPIRITSHOT -> 17;
case SUMMON_SOULSHOT -> 18;
case SUMMON_SPIRITSHOT -> 19;
default -> 0;
};
return id + ":" + type; // ← SEPARADOR CAMBIADO
}
else
{
return id + ":0";
}
})
.collect(Collectors.joining(","));
return String.format(
"%s|%s|%s|%s|%s|%s",
autoSupply,
_autoActions.stream().map(Object::toString).collect(Collectors.joining(",")),
_autoBuffs.stream().map(Object::toString).collect(Collectors.joining(",")),
_autoSkills.stream().map(Object::toString).collect(Collectors.joining(",")),
_autoPotionItem.get(),
_autoPetPotionItem.get()
);
}
public void deserialize(String data, Player player)
{
if (data == null || data.isEmpty())
{
return;
}
try
{
String[] parts = data.split("\\|", -1);
_autoSupplyItems.clear();
if (parts.length > 0 && !parts[0].isEmpty())
{
String[] entries = parts[0].split(",");
for (String entry : entries)
{
String[] split = entry.split(":");
int itemId = Integer.parseInt(split[0]);
int type = (split.length > 1) ? Integer.parseInt(split[1]) : 0;
_autoSupplyItems.add(itemId);
// Restaurar comportamiento según tipo
switch (type)
{
case 16, 17, 18, 19 -> AutoUseTaskManager.getInstance().addAutoSupplyItem(player, itemId);
}
}
}
_autoActions.clear();
if (parts.length > 1 && !parts[1].isEmpty())
{
Arrays.stream(parts[1].split(",")).map(Integer::parseInt).forEach(_autoActions::add);
}
_autoBuffs.clear();
if (parts.length > 2 && !parts[2].isEmpty())
{
Arrays.stream(parts[2].split(",")).map(Integer::parseInt).forEach(_autoBuffs::add);
}
_autoSkills.clear();
if (parts.length > 3 && !parts[3].isEmpty())
{
Arrays.stream(parts[3].split(",")).map(Integer::parseInt).forEach(_autoSkills::add);
}
if (parts.length > 4 && !parts[4].isEmpty())
{
_autoPotionItem.set(Integer.parseInt(parts[4]));
}
if (parts.length > 5 && !parts[5].isEmpty())
{
_autoPetPotionItem.set(Integer.parseInt(parts[5]));
}
}
catch (Exception e)
{
LOGGER.log(Level.WARNING, "Failed to deserialize AutoUseSettings: " + data, e);
}
}