GameBridge examples

Mobius GameBridge

Complete GameBridge source example based on Mobius CT_2.6 (High Five)

This is a source example for implementing your own bridge. Forgeport does not distribute or maintain a Mobius adapter.

The page prints the whole bridge — the composition class and each of its eleven helpers. The code implements the GameBridge interface from the current connector core JAR and was verified end-to-end on a stock Mobius CT_2.6 (High Five) checkout: connector check passed, and account linking, item and skill delivery, rename, gender change, unstuck, and account creation confirmed in the game client. Verify every API and database call against your exact Mobius revision before deployment.

Baseline assumptions

  • Package root: org.l2jmobius
  • Character identifier: characters.charId
  • Passwords: SHA-1 digest encoded with Base64
  • Live players: World.getInstance().getPlayers()
  • Database: DatabaseFactory
  • Grants and character services: online characters only

The example advertises:

VERIFY_GAME_CREDENTIALS
CREATE_GAME_ACCOUNT
GET_SERVER_STATUS
GET_ONLINE_COUNT
LIST_ACCOUNT_CHARACTERS
LIST_CHARACTER_SKILLS
LIST_RANKINGS
SEARCH_GAME_CATALOG
SEARCH_SKILL_CATALOG
DELIVER_ITEM_GRANT
DELIVER_SKILL_GRANT
CHARACTER_SERVICES
PLAYER_ONLINE_EVENT
UNSTUCK_CHARACTER

Complete MobiusGameBridge

Create:

java/org/l2jmobius/gameserver/portal/MobiusGameBridge.java

The bridge imports GameBridge and GrantResult from the Forgeport core JAR. The remaining imports and helpers belong to the game-specific Mobius code.

l2j/mobius-ct26-highfive/MobiusGameBridge.java on GitHub
MobiusGameBridge.java
package org.l2jmobius.gameserver.portal;

import org.l2jmobius.gameserver.model.World;
import org.l2jmobius.gameserver.portal.PortalCommandStore.CommandRecord;

import com.eclipsesource.json.JsonArray;
import com.eclipsesource.json.JsonObject;

import net.forgeport.connector.GameBridge;
import net.forgeport.connector.GrantResult;

/**
 * GameBridge example for a stock Mobius CT_2.6 (High Five) source.
 *
 * Each method delegates to a focused helper that owns the corresponding
 * Mobius database query or runtime operation.
 */
public final class MobiusGameBridge implements GameBridge
{
	@Override
	public String[] capabilities()
	{
		return new String[]
		{
			"VERIFY_GAME_CREDENTIALS",
			"CREATE_GAME_ACCOUNT",
			"GET_SERVER_STATUS",
			"GET_ONLINE_COUNT",
			"LIST_ACCOUNT_CHARACTERS",
			"LIST_CHARACTER_SKILLS",
			"LIST_RANKINGS",
			"SEARCH_GAME_CATALOG",
			"SEARCH_SKILL_CATALOG",
			"DELIVER_ITEM_GRANT",
			"DELIVER_SKILL_GRANT",
			"CHARACTER_SERVICES",
			"PLAYER_ONLINE_EVENT",
			"UNSTUCK_CHARACTER"
		};
	}

	@Override
	public void init()
	{
		PortalCommandStore.init();
	}

	@Override
	public int onlineCount()
	{
		return World.getInstance().getPlayers().size();
	}

	@Override
	public JsonArray listCharacters(String accountName) throws Exception
	{
		return CharacterLister.list(accountName);
	}

	@Override
	public JsonArray listCharacterSkills(String accountName, int characterId) throws Exception
	{
		return CharacterSkillLister.list(accountName, characterId);
	}

	@Override
	public JsonArray listRankings(String metric, int limit) throws Exception
	{
		return RankingLister.list(metric, limit);
	}

	@Override
	public String verifyCredentials(String accountName, String password) throws Exception
	{
		return GameCredentialVerifier.verify(accountName, password);
	}

	@Override
	public String createGameAccount(String accountName, String password) throws Exception
	{
		return GameAccountCreator.create(accountName, password);
	}

	@Override
	public String unstuck(String accountName, int characterId) throws Exception
	{
		return Unstucker.unstuck(accountName, characterId);
	}

	@Override
	public JsonArray searchCatalog(String term, int limit)
	{
		return CatalogSearcher.search(term, limit);
	}

	@Override
	public JsonArray searchSkillCatalog(String term, int limit)
	{
		return SkillCatalogSearcher.search(term, limit);
	}

	@Override
	public GrantResult applyGrant(String commandId, JsonObject payload)
	{
		final CommandRecord existing = PortalCommandStore.find(commandId);
		if (existing != null)
		{
			switch (existing.status())
			{
				case "APPLIED":
					return GrantResult.alreadyApplied();

				case "PERMANENT_FAILURE":
					return GrantResult.permanent(existing.errorCode());

				default:
					return GrantResult.reconciliation("AMBIGUOUS_PRIOR_ATTEMPT");
			}
		}

		try
		{
			final GrantApplier.Result result = GrantApplier.apply(commandId, payload);
			if ("APPLIED".equals(result.outcome()))
			{
				PortalCommandStore.settle(commandId, "APPLIED", null);
			}

			return new GrantResult(result.outcome(), result.durable(), result.errorCode());
		}
		catch (Exception e)
		{
			return GrantResult.reconciliation("ADAPTER_EXCEPTION");
		}
	}
}

The helper classes

MobiusGameBridge is the composition class; these eleven helpers are the rest of the bridge. All of them live in the same package, and every one is printed here in full — together with the composition class they are the complete source, verified end-to-end against a stock Mobius CT_2.6 checkout: connector check passed, and every delivery kind confirmed in the game client.

HelperResponsibility
CharacterListerQuery account characters and map them to protocol JSON
CharacterSkillListerRead a character's known skills for the active class, live world first
CharacterRowsThe row-to-protocol mapping shared by character lists and rankings
RankingListerQuery PvP, PK, and level rankings
GameCredentialVerifierVerify the Mobius SHA-1/Base64 password and account status
GameAccountCreatorValidate, hash, and insert a new game account
CatalogSearcherSearch loaded item templates
SkillCatalogSearcherSearch loaded skill templates
UnstuckerVerify ownership/offline status and update saved coordinates
PortalCommandStorePersist APPLYING and terminal command states
GrantApplierApply item, skill, rename, and gender-change payloads

The capability reference defines the inputs and outputs for every method. Remove a capability from capabilities() until its complete implementation and persistence behavior have been tested.

CharacterLister.java

l2j/mobius-ct26-highfive/CharacterLister.java on GitHub
CharacterLister.java
package org.l2jmobius.gameserver.portal;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

import org.l2jmobius.commons.database.DatabaseFactory;

import com.eclipsesource.json.JsonArray;

/**
 * Query account characters and map them to protocol JSON.
 */
final class CharacterLister
{
	private CharacterLister()
	{
	}

	static JsonArray list(String accountName) throws Exception
	{
		final JsonArray result = new JsonArray();
		try (Connection con = DatabaseFactory.getConnection();
			PreparedStatement ps = con.prepareStatement("SELECT " + CharacterRows.SELECT_COLUMNS + " FROM " + CharacterRows.FROM_JOIN + " WHERE c.account_name = ? ORDER BY c.level DESC LIMIT 30"))
		{
			ps.setString(1, accountName);
			try (ResultSet rs = ps.executeQuery())
			{
				while (rs.next())
				{
					result.add(CharacterRows.toJson(rs));
				}
			}
		}
		return result;
	}
}

CharacterSkillLister.java

l2j/mobius-ct26-highfive/CharacterSkillLister.java on GitHub
CharacterSkillLister.java
package org.l2jmobius.gameserver.portal;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

import org.l2jmobius.commons.database.DatabaseFactory;
import org.l2jmobius.gameserver.model.World;
import org.l2jmobius.gameserver.model.actor.Player;
import org.l2jmobius.gameserver.model.skill.Skill;

import com.eclipsesource.json.JsonArray;
import com.eclipsesource.json.JsonObject;

/**
 * Read a character's known skills for the active class, live world first.
 * Returns null when the account does not own the character; the core reports
 * NOT_FOUND. A query failure must throw, never answer with an empty list.
 */
final class CharacterSkillLister
{
	private CharacterSkillLister()
	{
	}

	static JsonArray list(String accountName, int characterId) throws Exception
	{
		// A skill learned this session is not in character_skills until the
		// character is saved, so the live character is the only current copy.
		final Player online = World.getInstance().getPlayer(characterId);
		if (online != null)
		{
			if (!accountName.equalsIgnoreCase(online.getAccountName()))
			{
				return null;
			}
			final JsonArray result = new JsonArray();
			for (Skill skill : online.getAllSkills())
			{
				result.add(new JsonObject().add("skillId", skill.getId()).add("level", skill.getLevel()));
			}
			return result;
		}

		if (!ownsCharacter(accountName, characterId))
		{
			return null;
		}

		final JsonArray result = new JsonArray();
		try (Connection con = DatabaseFactory.getConnection();
			// Mobius CT 2.6 has no characters.classIndex column: the active class
			// index is 0 on the base class, otherwise the matching subclass row.
			PreparedStatement ps = con.prepareStatement("SELECT s.skill_id, s.skill_level FROM character_skills s JOIN characters c ON c.charId = s.charId LEFT JOIN character_subclasses sub ON sub.charId = c.charId AND sub.class_id = c.classid WHERE s.charId = ? AND c.account_name = ? AND s.class_index = CASE WHEN c.classid = c.base_class THEN 0 ELSE COALESCE(sub.class_index, 0) END"))
		{
			ps.setInt(1, characterId);
			ps.setString(2, accountName);
			try (ResultSet rs = ps.executeQuery())
			{
				while (rs.next())
				{
					result.add(new JsonObject().add("skillId", rs.getInt("skill_id")).add("level", rs.getInt("skill_level")));
				}
			}
		}
		return result;
	}

	private static boolean ownsCharacter(String accountName, int characterId) throws Exception
	{
		try (Connection con = DatabaseFactory.getConnection();
			PreparedStatement ps = con.prepareStatement("SELECT 1 FROM characters WHERE charId = ? AND account_name = ?"))
		{
			ps.setInt(1, characterId);
			ps.setString(2, accountName);
			try (ResultSet rs = ps.executeQuery())
			{
				return rs.next();
			}
		}
	}
}

CharacterRows.java

l2j/mobius-ct26-highfive/CharacterRows.java on GitHub
CharacterRows.java
package org.l2jmobius.gameserver.portal;

import java.sql.ResultSet;

import org.l2jmobius.gameserver.model.World;

import com.eclipsesource.json.Json;
import com.eclipsesource.json.JsonObject;

/**
 * The row-to-protocol mapping shared by character lists and rankings.
 */
final class CharacterRows
{
	// Race ids as every L2 schema stores them.
	private static final String[] RACES =
	{
		"human",
		"elf",
		"darkelf",
		"orc",
		"dwarf",
		"kamael"
	};

	static final String SELECT_COLUMNS = "c.charId, c.char_name, c.level, c.classid, c.race, c.sex, c.online, c.pvpkills, c.pkkills, cl.clan_name";
	static final String FROM_JOIN = "characters c LEFT JOIN clan_data cl ON cl.clan_id = c.clanid";

	private CharacterRows()
	{
	}

	static JsonObject toJson(ResultSet rs) throws Exception
	{
		final int charId = rs.getInt("charId");
		final int race = rs.getInt("race");
		final String clanName = rs.getString("clan_name");
		final JsonObject row = new JsonObject();
		row.add("characterId", charId);
		row.add("name", rs.getString("char_name"));
		row.add("level", rs.getInt("level"));
		row.add("classId", rs.getInt("classid"));
		row.add("race", ((race >= 0) && (race < RACES.length)) ? RACES[race] : "human");
		row.add("sex", rs.getInt("sex") == 1 ? "female" : "male");
		row.add("online", (World.getInstance().getPlayer(charId) != null) || (rs.getInt("online") == 1));
		row.add("pvpKills", rs.getInt("pvpkills"));
		row.add("pkKills", rs.getInt("pkkills"));
		row.add("clanName", clanName == null ? Json.NULL : Json.value(clanName));
		return row;
	}
}

RankingLister.java

l2j/mobius-ct26-highfive/RankingLister.java on GitHub
RankingLister.java
package org.l2jmobius.gameserver.portal;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

import org.l2jmobius.commons.database.DatabaseFactory;

import com.eclipsesource.json.JsonArray;

/**
 * Query PvP, PK, and level rankings. Staff characters are excluded.
 */
final class RankingLister
{
	private RankingLister()
	{
	}

	static JsonArray list(String metric, int limit) throws Exception
	{
		final String order = switch (metric)
		{
			case "pvp" -> "c.pvpkills DESC, c.level DESC";
			case "pk" -> "c.pkkills DESC, c.level DESC";
			case "level" -> "c.level DESC, c.pvpkills DESC";
			default -> throw new IllegalArgumentException("Unknown ranking metric: " + metric);
		};

		final int clamped = Math.max(1, Math.min(100, limit));
		final JsonArray result = new JsonArray();
		try (Connection con = DatabaseFactory.getConnection();
			PreparedStatement ps = con.prepareStatement("SELECT " + CharacterRows.SELECT_COLUMNS + " FROM " + CharacterRows.FROM_JOIN + " WHERE c.accesslevel = 0 ORDER BY " + order + " LIMIT ?"))
		{
			ps.setInt(1, clamped);
			try (ResultSet rs = ps.executeQuery())
			{
				while (rs.next())
				{
					result.add(CharacterRows.toJson(rs));
				}
			}
		}
		return result;
	}
}

GameCredentialVerifier.java

l2j/mobius-ct26-highfive/GameCredentialVerifier.java on GitHub
GameCredentialVerifier.java
package org.l2jmobius.gameserver.portal;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.Base64;

import org.l2jmobius.commons.database.DatabaseFactory;

/**
 * Verify the Mobius SHA-1/Base64 password and account status, mirroring
 * loginserver LoginController#retriveAccountInfo.
 */
final class GameCredentialVerifier
{
	private GameCredentialVerifier()
	{
	}

	static String verify(String accountName, String password) throws Exception
	{
		final String storedHash;
		final int accessLevel;
		try (Connection con = DatabaseFactory.getConnection();
			PreparedStatement ps = con.prepareStatement("SELECT password, accessLevel FROM accounts WHERE login = ?"))
		{
			ps.setString(1, accountName);
			try (ResultSet rs = ps.executeQuery())
			{
				if (!rs.next())
				{
					return "NO_SUCH_ACCOUNT";
				}
				storedHash = rs.getString("password");
				accessLevel = rs.getInt("accessLevel");
			}
		}

		if (accessLevel < 0)
		{
			return "ACCOUNT_DISABLED";
		}

		return hash(password).equals(storedHash) ? "VALID" : "INVALID_CREDENTIALS";
	}

	static String hash(String password) throws Exception
	{
		final MessageDigest md = MessageDigest.getInstance("SHA");
		return Base64.getEncoder().encodeToString(md.digest(password.getBytes(StandardCharsets.UTF_8)));
	}
}

GameAccountCreator.java

l2j/mobius-ct26-highfive/GameAccountCreator.java on GitHub
GameAccountCreator.java
package org.l2jmobius.gameserver.portal;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLIntegrityConstraintViolationException;

import org.l2jmobius.commons.database.DatabaseFactory;

/**
 * Validate, hash, and insert a new game account. The account name arrives
 * already lower-cased and capped at 14 characters by the portal.
 */
final class GameAccountCreator
{
	private GameAccountCreator()
	{
	}

	static String create(String accountName, String password) throws Exception
	{
		if ((accountName == null) || !accountName.matches("^[a-z0-9]{4,14}$"))
		{
			return "INVALID_NAME";
		}

		try (Connection con = DatabaseFactory.getConnection();
			PreparedStatement ps = con.prepareStatement("INSERT INTO accounts (login, password) VALUES (?, ?)"))
		{
			ps.setString(1, accountName);
			ps.setString(2, GameCredentialVerifier.hash(password));
			ps.executeUpdate();
			return "CREATED";
		}
		catch (SQLIntegrityConstraintViolationException e)
		{
			// Two players can claim the same name between check and insert;
			// the duplicate key is ALREADY_EXISTS, not a fault.
			return "ALREADY_EXISTS";
		}
	}
}

CatalogSearcher.java

l2j/mobius-ct26-highfive/CatalogSearcher.java on GitHub
CatalogSearcher.java
package org.l2jmobius.gameserver.portal;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;

import org.l2jmobius.gameserver.data.xml.ItemData;
import org.l2jmobius.gameserver.model.item.ItemTemplate;
import org.l2jmobius.gameserver.model.item.enums.ItemGrade;

import com.eclipsesource.json.Json;
import com.eclipsesource.json.JsonArray;
import com.eclipsesource.json.JsonObject;

/**
 * Search loaded item templates in memory — the same objects the server uses,
 * so custom items are included with no extra work and no query.
 */
final class CatalogSearcher
{
	private CatalogSearcher()
	{
	}

	static JsonArray search(String term, int limit)
	{
		final int clamped = Math.max(1, Math.min(50, limit));
		final String needle = term == null ? "" : term.trim().toLowerCase(Locale.ROOT);
		final List<Match> matches = new ArrayList<>();
		for (ItemTemplate template : ItemData.getInstance().getAllItems())
		{
			if (template == null)
			{
				continue;
			}
			final int rank = rank(template.getId(), template.getName(), needle);
			if (rank >= 0)
			{
				matches.add(new Match(rank, template));
			}
		}
		matches.sort(Comparator.<Match> comparingInt(m -> m.rank).thenComparingInt(m -> m.template.getId()));

		final JsonArray result = new JsonArray();
		for (int i = 0; (i < matches.size()) && (i < clamped); i++)
		{
			final ItemTemplate template = matches.get(i).template;
			final ItemGrade grade = template.getItemGrade();
			final JsonObject row = new JsonObject();
			row.add("itemId", template.getId());
			row.add("name", template.getName());
			row.add("type", template.getClass().getSimpleName());
			row.add("grade", grade == ItemGrade.NONE ? Json.NULL : Json.value(grade.name()));
			row.add("stackable", template.isStackable());
			result.add(row);
		}
		return result;
	}

	/**
	 * Exact id or name first, then name prefix, then substring; -1 is no match.
	 */
	static int rank(int id, String name, String needle)
	{
		if (needle.isEmpty())
		{
			return 2;
		}
		final String lower = name == null ? "" : name.toLowerCase(Locale.ROOT);
		if (needle.equals(String.valueOf(id)) || needle.equals(lower))
		{
			return 0;
		}
		if (lower.startsWith(needle))
		{
			return 1;
		}
		if (lower.contains(needle))
		{
			return 2;
		}
		return -1;
	}

	private record Match(int rank, ItemTemplate template)
	{
	}
}

SkillCatalogSearcher.java

l2j/mobius-ct26-highfive/SkillCatalogSearcher.java on GitHub
SkillCatalogSearcher.java
package org.l2jmobius.gameserver.portal;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;

import org.l2jmobius.gameserver.data.xml.SkillData;
import org.l2jmobius.gameserver.model.skill.Skill;

import com.eclipsesource.json.JsonArray;
import com.eclipsesource.json.JsonObject;

/**
 * Search loaded skill templates. One entry per skill id, with maxLevel the
 * highest normal, non-enchanted level — enchant routes (101+, 201+) are out
 * of scope because the portal builders never offer them.
 */
final class SkillCatalogSearcher
{
	private SkillCatalogSearcher()
	{
	}

	static JsonArray search(String term, int limit)
	{
		final int clamped = Math.max(1, Math.min(50, limit));
		final String needle = term == null ? "" : term.trim().toLowerCase(Locale.ROOT);
		final Set<Integer> seen = new HashSet<>();
		final List<Match> matches = new ArrayList<>();
		for (Skill skill : SkillData.getInstance().getAllSkills())
		{
			if ((skill == null) || (skill.getLevel() > 99) || !seen.add(skill.getId()))
			{
				continue;
			}
			final int rank = CatalogSearcher.rank(skill.getId(), skill.getName(), needle);
			if (rank >= 0)
			{
				matches.add(new Match(rank, skill));
			}
		}
		matches.sort(Comparator.<Match> comparingInt(m -> m.rank).thenComparingInt(m -> m.skill.getId()));

		final JsonArray result = new JsonArray();
		for (int i = 0; (i < matches.size()) && (i < clamped); i++)
		{
			final Skill skill = matches.get(i).skill;
			result.add(new JsonObject().add("skillId", skill.getId()).add("name", skill.getName()).add("maxLevel", SkillData.getInstance().getMaxLevel(skill.getId())));
		}
		return result;
	}

	private record Match(int rank, Skill skill)
	{
	}
}

Unstucker.java

l2j/mobius-ct26-highfive/Unstucker.java on GitHub
Unstucker.java
package org.l2jmobius.gameserver.portal;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

import org.l2jmobius.commons.database.DatabaseFactory;
import org.l2jmobius.gameserver.model.World;

/**
 * Verify ownership/offline status and update saved coordinates. A logged-in
 * player owns its position in memory and writes it back on the next save, so
 * only an offline character can be moved safely.
 */
final class Unstucker
{
	private Unstucker()
	{
	}

	static String unstuck(String accountName, int characterId) throws Exception
	{
		final boolean offlineInDb;
		try (Connection con = DatabaseFactory.getConnection();
			PreparedStatement ps = con.prepareStatement("SELECT online FROM characters WHERE charId = ? AND account_name = ?"))
		{
			ps.setInt(1, characterId);
			ps.setString(2, accountName);
			try (ResultSet rs = ps.executeQuery())
			{
				if (!rs.next())
				{
					return "NOT_FOUND";
				}
				offlineInDb = rs.getInt("online") == 0;
			}
		}

		if ((World.getInstance().getPlayer(characterId) != null) || !offlineInDb)
		{
			return "CHARACTER_ONLINE";
		}

		// PortalConfig.load() has already read config/portal.properties.
		final int x = net.forgeport.connector.PortalConfig.UNSTUCK_X;
		final int y = net.forgeport.connector.PortalConfig.UNSTUCK_Y;
		final int z = net.forgeport.connector.PortalConfig.UNSTUCK_Z;

		try (Connection con = DatabaseFactory.getConnection();
			PreparedStatement ps = con.prepareStatement("UPDATE characters SET x = ?, y = ?, z = ? WHERE charId = ? AND account_name = ?"))
		{
			ps.setInt(1, x);
			ps.setInt(2, y);
			ps.setInt(3, z);
			ps.setInt(4, characterId);
			ps.setString(5, accountName);
			return ps.executeUpdate() == 1 ? "UNSTUCK" : "FAILED";
		}
	}
}

PortalCommandStore.java

l2j/mobius-ct26-highfive/PortalCommandStore.java on GitHub
PortalCommandStore.java
package org.l2jmobius.gameserver.portal;

import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.HashSet;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.l2jmobius.commons.database.DatabaseFactory;

/**
 * Command journal for portal grants. Persists command state before game state
 * changes so a replayed command is never applied twice.
 */
public final class PortalCommandStore
{
	private static final Logger LOGGER = Logger.getLogger(PortalCommandStore.class.getName());

	public record CommandRecord(String status, String errorCode)
	{
	}

	private PortalCommandStore()
	{
	}

	public static void init()
	{
		try (Connection con = DatabaseFactory.getConnection();
			Statement st = con.createStatement())
		{
			st.executeUpdate("CREATE TABLE IF NOT EXISTS portal_command (" //
				+ "command_id VARCHAR(36) NOT NULL PRIMARY KEY," //
				+ "status VARCHAR(24) NOT NULL," //
				+ "error_code VARCHAR(100) NULL," //
				+ "order_id VARCHAR(64) NULL," //
				+ "created_at BIGINT NOT NULL," //
				+ "applied_at BIGINT NULL)");

			// CREATE TABLE IF NOT EXISTS never migrates an existing table, so add
			// columns the journal gained after its first release through metadata.
			final Set<String> columns = new HashSet<>();
			final DatabaseMetaData meta = con.getMetaData();
			try (ResultSet rs = meta.getColumns(con.getCatalog(), null, "portal_command", null))
			{
				while (rs.next())
				{
					columns.add(rs.getString("COLUMN_NAME").toLowerCase());
				}
			}
			if (!columns.contains("error_code"))
			{
				st.executeUpdate("ALTER TABLE portal_command ADD COLUMN error_code VARCHAR(100) NULL");
			}
			if (!columns.contains("order_id"))
			{
				st.executeUpdate("ALTER TABLE portal_command ADD COLUMN order_id VARCHAR(64) NULL");
			}
			if (!columns.contains("applied_at"))
			{
				st.executeUpdate("ALTER TABLE portal_command ADD COLUMN applied_at BIGINT NULL");
			}
		}
		catch (Exception e)
		{
			LOGGER.log(Level.SEVERE, "PortalCommandStore: Could not initialize portal_command table.", e);
			throw new IllegalStateException("portal_command journal unavailable", e);
		}
	}

	public static CommandRecord find(String commandId)
	{
		try (Connection con = DatabaseFactory.getConnection();
			PreparedStatement ps = con.prepareStatement("SELECT status, error_code FROM portal_command WHERE command_id = ?"))
		{
			ps.setString(1, commandId);
			try (ResultSet rs = ps.executeQuery())
			{
				if (rs.next())
				{
					return new CommandRecord(rs.getString("status"), rs.getString("error_code"));
				}
			}
		}
		catch (Exception e)
		{
			LOGGER.log(Level.WARNING, "PortalCommandStore: Lookup failed for " + commandId, e);
			// An unreadable journal must not look like a fresh command.
			return new CommandRecord("UNKNOWN", null);
		}
		return null;
	}

	/**
	 * Persists the APPLYING marker. Must be called after every check that can
	 * decline and before the game mutation.
	 */
	public static void begin(String commandId, String orderId) throws Exception
	{
		try (Connection con = DatabaseFactory.getConnection();
			PreparedStatement ps = con.prepareStatement("INSERT INTO portal_command (command_id, status, order_id, created_at) VALUES (?, 'APPLYING', ?, ?)"))
		{
			ps.setString(1, commandId);
			ps.setString(2, orderId);
			ps.setLong(3, System.currentTimeMillis());
			ps.executeUpdate();
		}
	}

	public static void settle(String commandId, String status, String errorCode)
	{
		try (Connection con = DatabaseFactory.getConnection();
			PreparedStatement ps = con.prepareStatement("UPDATE portal_command SET status = ?, error_code = ?, applied_at = ? WHERE command_id = ?"))
		{
			ps.setString(1, status);
			ps.setString(2, errorCode);
			ps.setLong(3, System.currentTimeMillis());
			ps.setString(4, commandId);
			ps.executeUpdate();
		}
		catch (Exception e)
		{
			LOGGER.log(Level.SEVERE, "PortalCommandStore: Could not settle " + commandId + " as " + status, e);
		}
	}
}

GrantApplier.java

l2j/mobius-ct26-highfive/GrantApplier.java on GitHub
GrantApplier.java
package org.l2jmobius.gameserver.portal;

import org.l2jmobius.gameserver.data.sql.CharInfoTable;
import org.l2jmobius.gameserver.data.xml.ItemData;
import org.l2jmobius.gameserver.data.xml.NpcData;
import org.l2jmobius.gameserver.data.xml.SkillData;
import org.l2jmobius.gameserver.model.World;
import org.l2jmobius.gameserver.model.actor.Player;
import org.l2jmobius.gameserver.model.actor.enums.creature.Race;
import org.l2jmobius.gameserver.model.item.ItemTemplate;
import org.l2jmobius.gameserver.model.item.enums.ItemProcessType;
import org.l2jmobius.gameserver.model.skill.Skill;

import com.eclipsesource.json.JsonObject;
import com.eclipsesource.json.JsonValue;

/**
 * Apply item, skill, rename, and gender-change payloads. Every check that can
 * decline runs before the journal is written: a REFUSED releases the player's
 * coins on the claim that nothing was mutated.
 */
final class GrantApplier
{
	record Result(String outcome, boolean durable, String errorCode)
	{
		static Result applied()
		{
			return new Result("APPLIED", true, null);
		}

		static Result refused(String code)
		{
			return new Result("REFUSED", true, code);
		}

		static Result retryable(String code)
		{
			return new Result("RETRYABLE_FAILURE", false, code);
		}
	}

	private GrantApplier()
	{
	}

	static Result apply(String commandId, JsonObject payload) throws Exception
	{
		// The payload nests the grant fields under "grant" and the target
		// under "target": {commandId, orderId, grantActionId, target: {...},
		// grant: {type, schemaVersion, ...}}.
		final JsonValue grantValue = payload.get("grant");
		final JsonObject grant = ((grantValue != null) && grantValue.isObject()) ? grantValue.asObject() : payload;
		final String type = string(grant, "type");
		if (type == null)
		{
			return Result.refused("UNSUPPORTED_GRANT_TYPE");
		}

		final String orderId = string(payload, "orderId");

		final JsonValue targetValue = payload.get("target");
		final JsonObject target = ((targetValue != null) && targetValue.isObject()) ? targetValue.asObject() : payload;
		final String accountName = string(target, "normalizedAccountName");
		final int characterId = target.getInt("characterId", 0);
		if ((accountName == null) || (characterId == 0))
		{
			return Result.refused("INVALID_TARGET");
		}

		// Grants and character services act on online characters only.
		final Player player = World.getInstance().getPlayer(characterId);
		if (player == null)
		{
			return Result.retryable("CHARACTER_OFFLINE");
		}
		if (!accountName.equalsIgnoreCase(player.getAccountName()))
		{
			// Nothing further down would notice an id belonging to someone else.
			return Result.refused("CHARACTER_NOT_ON_ACCOUNT");
		}

		return switch (type)
		{
			case "l2.grant_item" -> grantItem(commandId, orderId, grant, player);
			case "l2.grant_skill" -> grantSkill(commandId, orderId, grant, player);
			case "l2.character_rename" -> rename(commandId, orderId, grant, player);
			case "l2.gender_change" -> genderChange(commandId, orderId, grant, player);
			default -> refuseWithMessage(player, "UNSUPPORTED_GRANT_TYPE", "this purchase type is not supported");
		};
	}

	private static Result grantItem(String commandId, String orderId, JsonObject payload, Player player) throws Exception
	{
		final int itemId = payload.getInt("itemId", 0);
		final long count = payload.getLong("count", 0);
		final int enchantLevel = payload.getInt("enchantLevel", 0);

		final ItemTemplate template = ItemData.getInstance().getTemplate(itemId);
		if (template == null)
		{
			return refuseWithMessage(player, "UNKNOWN_ITEM", "the item does not exist on this server");
		}
		if (count < 1)
		{
			return refuseWithMessage(player, "INVALID_COUNT", "the item count was invalid");
		}

		PortalCommandStore.begin(commandId, orderId);
		if (template.isStackable())
		{
			player.addItem(ItemProcessType.REWARD, itemId, count, enchantLevel, player, true);
		}
		else
		{
			// A non-stackable purchase needs per-unit handling to carry the enchant.
			for (long i = 0; i < count; i++)
			{
				player.addItem(ItemProcessType.REWARD, itemId, 1, enchantLevel, player, true);
			}
		}
		return Result.applied();
	}

	private static Result grantSkill(String commandId, String orderId, JsonObject payload, Player player) throws Exception
	{
		final int skillId = payload.getInt("skillId", 0);
		final int skillLevel = payload.getInt("skillLevel", 0);

		// SkillData.getSkill falls back to the max level for a too-high request
		// instead of returning null, which would deliver a level the player did
		// not buy — validate the exact level first.
		final int maxLevel = SkillData.getInstance().getMaxLevel(skillId);
		if ((skillLevel < 1) || (maxLevel == 0) || (skillLevel > maxLevel))
		{
			return refuseWithMessage(player, "UNKNOWN_SKILL", "the skill does not exist on this server");
		}
		final Skill skill = SkillData.getInstance().getSkill(skillId, skillLevel);
		if (skill == null)
		{
			return refuseWithMessage(player, "UNKNOWN_SKILL", "the skill does not exist on this server");
		}

		// The live character is the only current copy — a skill learned this
		// session is not in character_skills until the character is saved.
		final Skill known = player.getKnownSkill(skillId);
		if ((known != null) && (known.getLevel() >= skillLevel))
		{
			return refuseWithMessage(player, "SKILL_ALREADY_KNOWN", "the character already knows this skill");
		}

		PortalCommandStore.begin(commandId, orderId);
		player.addSkill(skill, true);
		player.sendSkillList();
		return Result.applied();
	}

	private static Result rename(String commandId, String orderId, JsonObject payload, Player player) throws Exception
	{
		final String newName = string(payload, "newName");
		if ((newName == null) || !newName.matches("^[A-Za-z0-9]{1,16}$"))
		{
			return refuseWithMessage(player, "INVALID_CHARACTER_NAME", "that name is not allowed");
		}
		if (NpcData.getInstance().getTemplateByName(newName) != null)
		{
			return refuseWithMessage(player, "RESERVED_CHARACTER_NAME", "that name is reserved");
		}
		if (CharInfoTable.getInstance().doesCharNameExist(newName))
		{
			return refuseWithMessage(player, "CHARACTER_NAME_TAKEN", "that name is already taken");
		}

		PortalCommandStore.begin(commandId, orderId);
		player.setName(newName);
		// A rename that skips the name index leaves the character findable
		// only under the name it no longer has.
		CharInfoTable.getInstance().addName(player);
		player.storeMe();
		player.broadcastUserInfo();
		return Result.applied();
	}

	private static Result genderChange(String commandId, String orderId, JsonObject payload, Player player) throws Exception
	{
		final String newSex = string(payload, "newSex");
		if (!"male".equals(newSex) && !"female".equals(newSex))
		{
			return refuseWithMessage(player, "INVALID_CHARACTER_SEX", "that gender value is not valid");
		}
		if (player.getRace() == Race.KAMAEL)
		{
			return refuseWithMessage(player, "KAMAEL_GENDER_LOCKED", "Kamael classes are tied to gender");
		}
		final boolean wantFemale = "female".equals(newSex);
		if (player.getAppearance().isFemale() == wantFemale)
		{
			return refuseWithMessage(player, "GENDER_UNCHANGED", "the character already has that gender");
		}

		PortalCommandStore.begin(commandId, orderId);
		// A plain appearance update: the decay-and-respawn cycle of //set sex
		// causes a client-side protection fault on the local character. The
		// new model appears after the relog the player is getting anyway.
		if (wantFemale)
		{
			player.getAppearance().setFemale();
		}
		else
		{
			player.getAppearance().setMale();
		}
		player.storeMe();
		player.broadcastUserInfo();
		return Result.applied();
	}

	private static Result refuseWithMessage(Player player, String code, String because)
	{
		player.sendMessage("Portal: your purchase was not delivered — " + because + ".");
		return Result.refused(code);
	}

	private static String string(JsonObject json, String name)
	{
		final JsonValue value = json.get(name);
		return ((value != null) && value.isString()) ? value.asString() : null;
	}
}

Skill catalog accessor

Mobius keeps the loaded skills in a private map. The example requires this read-only accessor in org.l2jmobius.gameserver.data.xml.SkillData:

l2j/mobius-ct26-highfive/SkillData.java on GitHub
SkillData.java
public Collection<Skill> getAllSkills()
{
	return java.util.Collections.unmodifiableCollection(_skillsByHash.values());
}

If your revision has no equivalent accessor, remove SEARCH_SKILL_CATALOG from capabilities().

Startup and shutdown

After world and data initialization:

import net.forgeport.connector.PortalConfig;
import net.forgeport.connector.PortalConnector;

PortalConfig.load();
PortalConnector.getInstance().start(new MobiusGameBridge());

During shutdown:

try
{
	PortalConnector.getInstance().shutdown();
}
catch (Exception ignored)
{
}

After player.setEnteredWorld() in EnterWorld:

import net.forgeport.connector.PortalConnector;

PortalConnector.getInstance().playerOnline(
	client.getAccountName(),
	player.getObjectId()
);

portal.properties

Download the file from Admin → Game Servers and place it at game/config/portal.properties:

config/portal.properties
Enabled=True
GatewayUrl=wss://wss.forgeport.net/
Token=gps_<game-server-id>.<secret>

# Optional. The default is 15 seconds.
#HeartbeatSeconds=15

# Optional tuning; the defaults are what this bridge does.
#DeliveryWindow.DELIVER_ITEM_GRANT=online
#Unstuck.X=82698
#Unstuck.Y=148638
#Unstuck.Z=-3473

Keep this file out of version control.

Build and verify

  1. Place portal-connector-core-<version>.jar and minimal-json-0.9.5.jar in the Mobius library directory.

  2. Add the bridge and its game-specific helper classes to the source tree.

  3. Add the skill accessor or remove its capability.

  4. Build:

    ant jar

    compile alone produces no jar — jar rebuilds GameServer.jar into build/dist/libs, and the default target builds the full distribution zip.

  5. Start the game server and confirm Connected.

  6. Run Connector check with a designated online character.

On this page