aCis GameBridge
Complete GameBridge source example based on aCis 409
This is a source example for implementing your own bridge. Forgeport does not distribute or maintain an aCis adapter.
The page prints the whole bridge — the composition class and each of its ten helpers. The code comes from the working aCis 409 integration the L2J delivery findings were measured on, and it compiles against the current connector core JAR. Verify every API and database call against your exact aCis revision before deployment.
Baseline assumptions
- Package root:
net.sf.l2j - Character identifier:
characters.obj_Id - Passwords: aCis bcrypt
- Live players:
World.getInstance().getPlayers() - Database: the aCis connection pool
- 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_CHARACTERComplete AcisGameBridge
Create:
aCis_gameserver/java/net/sf/l2j/gameserver/portal/AcisGameBridge.javaUse the following class as the starting point.
l2j/acis-409/AcisGameBridge.java on GitHubpackage net.sf.l2j.gameserver.portal;
import net.sf.l2j.commons.logging.CLogger;
import net.sf.l2j.gameserver.model.World;
import net.sf.l2j.gameserver.portal.PortalCommandStore.CommandRecord;
import com.eclipsesource.json.JsonArray;
import com.eclipsesource.json.JsonObject;
import net.forgeport.connector.GameBridge;
import net.forgeport.connector.GrantResult;
/**
* {@link GameBridge} example for a stock aCis 409 source. It runs in-process,
* so it reaches the live {@link World} — grants to online players apply
* instantly. Each method delegates to the focused helper that knows the aCis
* schema and API.
*/
public final class AcisGameBridge implements GameBridge
{
private static final CLogger LOGGER = new CLogger(AcisGameBridge.class.getName());
@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)
{
LOGGER.error("Grant {} failed with an exception.", e, commandId);
return GrantResult.reconciliation("ADAPTER_EXCEPTION");
}
}
}The helper classes
AcisGameBridge is the composition class; these ten helpers are the rest of
the bridge. All of them live in the same package, and every one is printed
here in full:
| Helper | Responsibility |
|---|---|
CharacterLister | Query account characters and map them to protocol JSON |
CharacterSkillLister | Read a character's known skills for the active class, live world first |
RankingLister | Query PvP, PK, and level rankings |
GameCredentialVerifier | Verify the aCis bcrypt password and account status |
GameAccountCreator | Validate, hash, and insert a new game account |
CatalogSearcher | Search loaded item templates |
SkillCatalogSearcher | Search loaded skill templates |
Unstucker | Verify ownership/offline status and update saved coordinates |
PortalCommandStore | Persist APPLYING and terminal command states |
GrantApplier | Apply 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/acis-409/CharacterLister.java on GitHub
package net.sf.l2j.gameserver.portal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import net.sf.l2j.commons.pool.ConnectionPool;
import com.eclipsesource.json.JsonArray;
import com.eclipsesource.json.JsonObject;
/**
* Builds Portal character summaries for one game account, straight from the database so offline
* characters are included.
*/
public final class CharacterLister
{
private static final String SELECT_CHARACTERS = "SELECT c.obj_Id, c.char_name, c.level, c.classid, c.sex, c.race, c.online, c.pvpkills, c.pkkills, cl.clan_name FROM characters c LEFT JOIN clan_data cl ON cl.clan_id = c.clanid WHERE c.account_name = ? ORDER BY c.level DESC LIMIT 30";
private static final String[] RACES =
{
"human",
"elf",
"darkelf",
"orc",
"dwarf"
};
/**
* @param accountName : The account to inspect.
* @return the protocol characters array.
* @throws Exception on database failure; the caller reports FAILED to the gateway.
*/
public static JsonArray list(String accountName) throws Exception
{
final JsonArray characters = new JsonArray();
try (Connection con = ConnectionPool.getConnection();
PreparedStatement ps = con.prepareStatement(SELECT_CHARACTERS))
{
ps.setString(1, accountName);
try (ResultSet rs = ps.executeQuery())
{
while (rs.next())
{
final int race = rs.getInt("race");
final JsonObject character = new JsonObject();
character.add("characterId", rs.getInt("obj_Id"));
character.add("name", rs.getString("char_name"));
character.add("level", Math.max(1, Math.min(99, rs.getInt("level"))));
character.add("classId", rs.getInt("classid"));
character.add("race", RACES[Math.max(0, Math.min(RACES.length - 1, race))]);
character.add("sex", rs.getInt("sex") == 0 ? "male" : "female");
character.add("online", rs.getInt("online") == 1);
character.add("pvpKills", Math.max(0, rs.getInt("pvpkills")));
character.add("pkKills", Math.max(0, rs.getInt("pkkills")));
final String clanName = rs.getString("clan_name");
if (clanName == null || clanName.isEmpty())
character.add("clanName", com.eclipsesource.json.Json.NULL);
else
character.add("clanName", clanName);
characters.add(character);
}
}
}
return characters;
}
private CharacterLister()
{
}
}CharacterSkillLister.java
l2j/acis-409/CharacterSkillLister.java on GitHub
package net.sf.l2j.gameserver.portal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import net.sf.l2j.commons.pool.ConnectionPool;
import net.sf.l2j.gameserver.model.World;
import net.sf.l2j.gameserver.model.actor.Player;
import net.sf.l2j.gameserver.skills.L2Skill;
import com.eclipsesource.json.JsonArray;
import com.eclipsesource.json.JsonObject;
/**
* What a character already knows, so the portal can decline to sell a level it holds rather than
* take the money and refuse at delivery.
* <p>
* A live character answers from memory and everyone else from the database: a skill learned this
* session is not written back until the character is saved, and selling it in the meantime is the
* exact purchase this query exists to prevent.
* <p>
* Only the active class. {@code character_skills} keeps a row per class index, so answering with
* all of them would refuse a skill the player genuinely cannot use on the class they are playing.
*/
public final class CharacterSkillLister
{
// characters has no class-index column: the active index is 0 on the base class, otherwise the
// class_index of the character_subclasses row matching the current classid.
private static final String SELECT_SKILLS = "SELECT s.skill_id, s.skill_level FROM character_skills s JOIN characters c ON c.obj_Id = s.char_obj_id LEFT JOIN character_subclasses sub ON sub.char_obj_id = c.obj_Id AND sub.class_id = c.classid WHERE s.char_obj_id = ? AND c.account_name = ? AND s.class_index = CASE WHEN c.classid = c.base_class THEN 0 ELSE COALESCE(sub.class_index, 0) END";
private static final String SELECT_OWNER = "SELECT 1 FROM characters WHERE obj_Id = ? AND account_name = ?";
/**
* @param accountName : The account that must own the character.
* @param characterId : The character to inspect.
* @return the skills array, or null when that account does not own that character. Null and
* empty are different answers: a fresh character knows nothing, and saying so is not
* the same as saying the character is not there.
* @throws Exception on database failure; the caller reports FAILED to the gateway.
*/
public static JsonArray list(String accountName, int characterId) throws Exception
{
final Player online = World.getInstance().getPlayer(characterId);
if (online != null && accountName.equalsIgnoreCase(online.getAccountName()))
{
final JsonArray skills = new JsonArray();
for (L2Skill skill : online.getSkills().values())
skills.add(entry(skill.getId(), skill.getLevel()));
return skills;
}
try (Connection con = ConnectionPool.getConnection())
{
final JsonArray skills = new JsonArray();
boolean owned = false;
try (PreparedStatement ps = con.prepareStatement(SELECT_SKILLS))
{
ps.setInt(1, characterId);
ps.setString(2, accountName);
try (ResultSet rs = ps.executeQuery())
{
while (rs.next())
{
owned = true;
skills.add(entry(rs.getInt("skill_id"), rs.getInt("skill_level")));
}
}
}
if (owned)
return skills;
try (PreparedStatement ps = con.prepareStatement(SELECT_OWNER))
{
ps.setInt(1, characterId);
ps.setString(2, accountName);
try (ResultSet rs = ps.executeQuery())
{
return rs.next() ? skills : null;
}
}
}
}
private static JsonObject entry(int skillId, int level)
{
return new JsonObject().add("skillId", skillId).add("level", level);
}
private CharacterSkillLister()
{
}
}RankingLister.java
l2j/acis-409/RankingLister.java on GitHub
package net.sf.l2j.gameserver.portal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import net.sf.l2j.commons.pool.ConnectionPool;
import com.eclipsesource.json.JsonArray;
import com.eclipsesource.json.JsonObject;
/**
* Builds Portal leaderboard rows straight from the database so offline characters count too. GM
* characters (accesslevel > 0) are excluded from public rankings.
*/
public final class RankingLister
{
private static final String SELECT_BASE = "SELECT c.obj_Id, c.char_name, c.level, c.classid, c.sex, c.race, c.online, c.pvpkills, c.pkkills, cl.clan_name FROM characters c LEFT JOIN clan_data cl ON cl.clan_id = c.clanid WHERE c.accesslevel = 0 ORDER BY ";
private static final String[] RACES =
{
"human",
"elf",
"darkelf",
"orc",
"dwarf"
};
/**
* @param metric : One of pvp/pk/level; anything else falls back to pvp.
* @param limit : Maximum rows, clamped to 1..100.
* @return the protocol characters array, best first.
* @throws Exception on database failure; the caller reports FAILED to the gateway.
*/
public static JsonArray list(String metric, int limit) throws Exception
{
final String orderBy;
switch (metric)
{
case "pk":
orderBy = "c.pkkills DESC, c.level DESC";
break;
case "level":
orderBy = "c.level DESC, c.pvpkills DESC";
break;
default:
orderBy = "c.pvpkills DESC, c.level DESC";
break;
}
final JsonArray characters = new JsonArray();
try (Connection con = ConnectionPool.getConnection();
PreparedStatement ps = con.prepareStatement(SELECT_BASE + orderBy + " LIMIT ?"))
{
ps.setInt(1, Math.max(1, Math.min(100, limit)));
try (ResultSet rs = ps.executeQuery())
{
while (rs.next())
{
final int race = rs.getInt("race");
final JsonObject character = new JsonObject();
character.add("characterId", rs.getInt("obj_Id"));
character.add("name", rs.getString("char_name"));
character.add("level", Math.max(1, Math.min(99, rs.getInt("level"))));
character.add("classId", rs.getInt("classid"));
character.add("race", RACES[Math.max(0, Math.min(RACES.length - 1, race))]);
character.add("sex", rs.getInt("sex") == 0 ? "male" : "female");
character.add("online", rs.getInt("online") == 1);
character.add("pvpKills", Math.max(0, rs.getInt("pvpkills")));
character.add("pkKills", Math.max(0, rs.getInt("pkkills")));
final String clanName = rs.getString("clan_name");
if (clanName == null || clanName.isEmpty())
character.add("clanName", com.eclipsesource.json.Json.NULL);
else
character.add("clanName", clanName);
characters.add(character);
}
}
}
return characters;
}
private RankingLister()
{
}
}GameCredentialVerifier.java
l2j/acis-409/GameCredentialVerifier.java on GitHub
package net.sf.l2j.gameserver.portal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import net.sf.l2j.commons.crypt.BCrypt;
import net.sf.l2j.commons.pool.ConnectionPool;
/** Verifies a game login without retaining or logging the supplied password. */
public final class GameCredentialVerifier
{
private static final String SELECT_ACCOUNT = "SELECT password, access_level FROM accounts WHERE login = ?";
public static String verify(String accountName, String password) throws Exception
{
try (Connection con = ConnectionPool.getConnection(); PreparedStatement ps = con.prepareStatement(SELECT_ACCOUNT))
{
ps.setString(1, accountName);
try (ResultSet rs = ps.executeQuery())
{
if (!rs.next())
return "INVALID_CREDENTIALS";
if (rs.getInt("access_level") < 0)
return "ACCOUNT_DISABLED";
return BCrypt.checkPw(password, rs.getString("password")) ? "VALID" : "INVALID_CREDENTIALS";
}
}
}
private GameCredentialVerifier()
{
}
}GameAccountCreator.java
l2j/acis-409/GameAccountCreator.java on GitHub
package net.sf.l2j.gameserver.portal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.regex.Pattern;
import net.sf.l2j.commons.crypt.BCrypt;
import net.sf.l2j.commons.pool.ConnectionPool;
/**
* Creates a game login on demand for the portal's self-service registration,
* without retaining or logging the supplied password. The account is inserted
* with a fresh BCrypt hash exactly as the login server would store it, so the
* player can log into the game client immediately.
*/
public final class GameAccountCreator
{
/** aCis logins are lower-cased alphanumerics; the gateway already lower-cases. */
private static final Pattern VALID_LOGIN = Pattern.compile("^[a-z0-9]{4,45}$");
private static final String SELECT_ACCOUNT = "SELECT login FROM accounts WHERE login = ?";
private static final String INSERT_ACCOUNT = "INSERT INTO accounts (login, password) VALUES (?, ?)";
public static String create(String accountName, String password) throws Exception
{
if (accountName == null || !VALID_LOGIN.matcher(accountName).matches())
return "INVALID_NAME";
try (Connection con = ConnectionPool.getConnection())
{
try (PreparedStatement check = con.prepareStatement(SELECT_ACCOUNT))
{
check.setString(1, accountName);
try (ResultSet rs = check.executeQuery())
{
if (rs.next())
return "ALREADY_EXISTS";
}
}
try (PreparedStatement insert = con.prepareStatement(INSERT_ACCOUNT))
{
insert.setString(1, accountName);
insert.setString(2, BCrypt.hashPw(password));
insert.executeUpdate();
return "CREATED";
}
catch (SQLException e)
{
// login is the primary key: a concurrent create lands here as a
// duplicate-key violation, which is the same outcome as ALREADY_EXISTS.
if (isDuplicateKey(e))
return "ALREADY_EXISTS";
throw e;
}
}
}
private static boolean isDuplicateKey(SQLException e)
{
return "23000".equals(e.getSQLState()) || e.getErrorCode() == 1062;
}
private GameAccountCreator()
{
}
}CatalogSearcher.java
l2j/acis-409/CatalogSearcher.java on GitHub
package net.sf.l2j.gameserver.portal;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import net.sf.l2j.gameserver.data.xml.ItemData;
import net.sf.l2j.gameserver.model.item.kind.Armor;
import net.sf.l2j.gameserver.model.item.kind.EtcItem;
import net.sf.l2j.gameserver.model.item.kind.Item;
import net.sf.l2j.gameserver.model.item.kind.Weapon;
import com.eclipsesource.json.JsonArray;
import com.eclipsesource.json.JsonObject;
/**
* Answers {@code SEARCH_GAME_CATALOG} from the in-memory item templates, so the
* Portal shop can offer item-name autocomplete instead of asking owners to know
* numeric item ids. Read-only; touches no player state.
*/
public final class CatalogSearcher
{
/**
* @param term : Case-insensitive substring of the item name (id also matches
* when the term is numeric).
* @param limit : Maximum rows, clamped to 1..50.
* @return the protocol catalog array, best matches first.
*/
public static JsonArray search(String term, int limit)
{
final int cap = Math.max(1, Math.min(limit, 50));
final String needle = term.toLowerCase(Locale.ROOT).trim();
final Integer idNeedle = parseId(needle);
// Rank: exact name, then prefix, then substring — so "Blessed Enchant"
// surfaces the obvious items before incidental substring hits.
final List<Item> exact = new ArrayList<>();
final List<Item> prefix = new ArrayList<>();
final List<Item> contains = new ArrayList<>();
for (Item item : ItemData.getInstance().getTemplates())
{
if (item == null)
continue;
final String name = item.getName();
if (name == null || name.isEmpty())
continue;
if (idNeedle != null && item.getItemId() == idNeedle.intValue())
{
exact.add(item);
continue;
}
final String lower = name.toLowerCase(Locale.ROOT);
if (lower.equals(needle))
exact.add(item);
else if (lower.startsWith(needle))
prefix.add(item);
else if (lower.contains(needle))
contains.add(item);
}
final JsonArray items = new JsonArray();
appendUntilFull(items, exact, cap);
appendUntilFull(items, prefix, cap);
appendUntilFull(items, contains, cap);
return items;
}
private static void appendUntilFull(JsonArray items, List<Item> source, int cap)
{
for (Item item : source)
{
if (items.size() >= cap)
return;
items.add(toJson(item));
}
}
private static JsonObject toJson(Item item)
{
final JsonObject entry = new JsonObject();
entry.add("itemId", item.getItemId());
entry.add("name", item.getName());
entry.add("type", typeOf(item));
entry.add("grade", gradeOf(item));
entry.add("stackable", item.isStackable());
return entry;
}
private static String typeOf(Item item)
{
if (item instanceof Weapon)
return "Weapon";
if (item instanceof Armor)
return "Armor";
if (item instanceof EtcItem)
return "EtcItem";
return "Item";
}
private static String gradeOf(Item item)
{
try
{
return item.getCrystalType().name();
}
catch (Exception e)
{
return null;
}
}
private static Integer parseId(String value)
{
if (value.isEmpty() || value.length() > 9)
return null;
for (int i = 0; i < value.length(); i++)
{
if (!Character.isDigit(value.charAt(i)))
return null;
}
try
{
return Integer.valueOf(value);
}
catch (NumberFormatException e)
{
return null;
}
}
private CatalogSearcher()
{
}
}SkillCatalogSearcher.java
l2j/acis-409/SkillCatalogSearcher.java on GitHub
package net.sf.l2j.gameserver.portal;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import net.sf.l2j.gameserver.data.SkillTable;
import net.sf.l2j.gameserver.skills.L2Skill;
import com.eclipsesource.json.JsonArray;
import com.eclipsesource.json.JsonObject;
/**
* Answers {@code SEARCH_SKILL_CATALOG} from aCis' in-memory skill table. One
* result is returned per skill id together with the highest normal level.
*/
public final class SkillCatalogSearcher
{
public static JsonArray search(String term, int limit)
{
final int cap = Math.max(1, Math.min(limit, 50));
final String needle = term.toLowerCase(Locale.ROOT).trim();
final Integer idNeedle = parseId(needle);
final Map<Integer, L2Skill> unique = new HashMap<>();
for (L2Skill skill : SkillTable.getInstance().getSkills())
{
if (skill == null || skill.getLevel() >= 99)
continue;
final int maxLevel = SkillTable.getInstance().getMaxLevel(skill.getId());
if (maxLevel <= 0)
continue;
final L2Skill current = unique.get(skill.getId());
if (current == null || skill.getLevel() > current.getLevel())
unique.put(skill.getId(), skill);
}
final List<L2Skill> exact = new ArrayList<>();
final List<L2Skill> prefix = new ArrayList<>();
final List<L2Skill> contains = new ArrayList<>();
for (L2Skill skill : unique.values())
{
final String name = skill.getName();
if (name == null || name.isEmpty())
continue;
if (idNeedle != null && skill.getId() == idNeedle.intValue())
{
exact.add(skill);
continue;
}
final String lower = name.toLowerCase(Locale.ROOT);
if (lower.equals(needle))
exact.add(skill);
else if (lower.startsWith(needle))
prefix.add(skill);
else if (lower.contains(needle))
contains.add(skill);
}
sortByNameAndId(exact);
sortByNameAndId(prefix);
sortByNameAndId(contains);
final JsonArray skills = new JsonArray();
appendUntilFull(skills, exact, cap);
appendUntilFull(skills, prefix, cap);
appendUntilFull(skills, contains, cap);
return skills;
}
private static void sortByNameAndId(List<L2Skill> skills)
{
skills.sort((left, right) ->
{
final int byName = left.getName().compareToIgnoreCase(right.getName());
return byName != 0 ? byName : Integer.compare(left.getId(), right.getId());
});
}
private static void appendUntilFull(JsonArray skills, List<L2Skill> source, int cap)
{
for (L2Skill skill : source)
{
if (skills.size() >= cap)
return;
skills.add(new JsonObject()
.add("skillId", skill.getId())
.add("name", skill.getName())
.add("maxLevel", SkillTable.getInstance().getMaxLevel(skill.getId())));
}
}
private static Integer parseId(String value)
{
if (value.isEmpty() || value.length() > 9)
return null;
for (int i = 0; i < value.length(); i++)
{
if (!Character.isDigit(value.charAt(i)))
return null;
}
try
{
return Integer.valueOf(value);
}
catch (NumberFormatException e)
{
return null;
}
}
private SkillCatalogSearcher()
{
}
}Unstucker.java
l2j/acis-409/Unstucker.java on GitHub
package net.sf.l2j.gameserver.portal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import net.sf.l2j.commons.pool.ConnectionPool;
import net.forgeport.connector.PortalConfig;
/**
* Moves a stuck, offline character back to a safe town by rewriting its saved
* coordinates straight in the database. Only offline characters may be moved:
* a logged-in player owns its live position, so touching the row underneath it
* would be overwritten on the next save and could corrupt state.
*/
public final class Unstucker
{
// Read from config/portal.properties (Unstuck.X/Y/Z), so a different recovery
// point does not need a rebuild. Defaults to Giran on retail coordinates.
private static final String SELECT_CHARACTER = "SELECT online FROM characters WHERE account_name = ? AND obj_Id = ?";
private static final String MOVE_CHARACTER = "UPDATE characters SET x = ?, y = ?, z = ? WHERE obj_Id = ?";
/**
* @param accountName : The account that must own the character.
* @param characterId : The character's obj_Id.
* @return one of UNSTUCK, CHARACTER_ONLINE, NOT_FOUND.
* @throws Exception on database failure; the caller reports FAILED to the gateway.
*/
public static String unstuck(String accountName, int characterId) throws Exception
{
try (Connection con = ConnectionPool.getConnection())
{
try (PreparedStatement ps = con.prepareStatement(SELECT_CHARACTER))
{
ps.setString(1, accountName);
ps.setInt(2, characterId);
try (ResultSet rs = ps.executeQuery())
{
if (!rs.next())
return "NOT_FOUND";
if (rs.getInt("online") == 1)
return "CHARACTER_ONLINE";
}
}
try (PreparedStatement ps = con.prepareStatement(MOVE_CHARACTER))
{
ps.setInt(1, PortalConfig.UNSTUCK_X);
ps.setInt(2, PortalConfig.UNSTUCK_Y);
ps.setInt(3, PortalConfig.UNSTUCK_Z);
ps.setInt(4, characterId);
ps.executeUpdate();
}
}
return "UNSTUCK";
}
private Unstucker()
{
}
}PortalCommandStore.java
l2j/acis-409/PortalCommandStore.java on GitHub
package net.sf.l2j.gameserver.portal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import net.sf.l2j.commons.logging.CLogger;
import net.sf.l2j.commons.pool.ConnectionPool;
/**
* Durable record of Portal command outcomes, keyed by immutable command id.<br>
* <br>
* The Portal gateway may replay any command after a reconnect or crash; this table is what turns a
* replay into ALREADY_APPLIED instead of a duplicate grant. A command found in APPLYING state on
* replay is ambiguous (crash mid-application) and must surface as RECONCILIATION_REQUIRED.
*/
public final class PortalCommandStore
{
private static final CLogger LOGGER = new CLogger(PortalCommandStore.class.getName());
private static final String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS portal_command (command_id VARCHAR(36) NOT NULL, status VARCHAR(24) NOT NULL, error_code VARCHAR(100) NULL, order_id VARCHAR(64) NULL, created_at BIGINT NOT NULL, applied_at BIGINT NULL, PRIMARY KEY (command_id))";
private static final String SELECT = "SELECT status, error_code FROM portal_command WHERE command_id = ?";
private static final String INSERT_APPLYING = "INSERT INTO portal_command (command_id, order_id, status, created_at) VALUES (?, ?, 'APPLYING', ?)";
private static final String UPDATE_STATUS = "UPDATE portal_command SET status = ?, error_code = ?, applied_at = ? WHERE command_id = ?";
/** Columns CREATE TABLE gained after the first release, for journals that predate them. */
private static final String[][] ADDED_SINCE =
{
{
"order_id",
"VARCHAR(64) NULL"
},
};
public record CommandRecord(String status, String errorCode)
{
}
public static void init()
{
try (Connection con = ConnectionPool.getConnection())
{
try (PreparedStatement ps = con.prepareStatement(CREATE_TABLE))
{
ps.execute();
}
// CREATE TABLE IF NOT EXISTS does nothing at all to a table that already exists, so a
// journal written by an earlier bridge keeps its old shape and every INSERT fails on the
// missing column — as a retryable error, which retries for as long as the server runs.
// Adding what is missing is the table owner keeping its own table usable.
addMissingColumns(con);
}
catch (Exception e)
{
LOGGER.error("Failed to create portal_command table.", e);
}
}
private static void addMissingColumns(Connection con) throws Exception
{
for (String[] column : ADDED_SINCE)
{
if (hasColumn(con, column[0]))
{
continue;
}
try (PreparedStatement ps = con.prepareStatement("ALTER TABLE portal_command ADD COLUMN " + column[0] + " " + column[1]))
{
ps.execute();
LOGGER.info("Added portal_command." + column[0] + " to an existing journal.");
}
}
}
private static boolean hasColumn(Connection con, String name) throws Exception
{
// Through JDBC metadata rather than a vendor-specific IF NOT EXISTS: the reference bridges
// run on MySQL and MariaDB, and only one of the two accepts that clause.
try (ResultSet rs = con.getMetaData().getColumns(con.getCatalog(), null, "portal_command", name))
{
return rs.next();
}
}
public static CommandRecord find(String commandId)
{
try (Connection con = ConnectionPool.getConnection();
PreparedStatement ps = con.prepareStatement(SELECT))
{
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.error("Failed to read portal_command {}.", e, commandId);
}
return null;
}
/**
* @param commandId : The command to mark.
* @return true if the APPLYING marker was inserted, false on failure (the command must not be applied).
*/
public static boolean markApplying(String commandId, String orderId)
{
try (Connection con = ConnectionPool.getConnection();
PreparedStatement ps = con.prepareStatement(INSERT_APPLYING))
{
ps.setString(1, commandId);
// The purchase this command belongs to, so "where is my item" is a query rather than
// a guess. Recorded, never matched on: the command id is what makes a replay safe.
ps.setString(2, orderId);
ps.setLong(3, System.currentTimeMillis());
return ps.executeUpdate() == 1;
}
catch (Exception e)
{
LOGGER.error("Failed to mark portal_command {} as APPLYING.", e, commandId);
return false;
}
}
public static void settle(String commandId, String status, String errorCode)
{
try (Connection con = ConnectionPool.getConnection();
PreparedStatement ps = con.prepareStatement(UPDATE_STATUS))
{
ps.setString(1, status);
ps.setString(2, errorCode);
ps.setLong(3, System.currentTimeMillis());
ps.setString(4, commandId);
ps.executeUpdate();
}
catch (Exception e)
{
LOGGER.error("Failed to settle portal_command {} as {}.", e, commandId, status);
}
}
private PortalCommandStore()
{
}
}GrantApplier.java
l2j/acis-409/GrantApplier.java on GitHub
package net.sf.l2j.gameserver.portal;
import net.sf.l2j.commons.logging.CLogger;
import net.sf.l2j.gameserver.data.SkillTable;
import net.sf.l2j.gameserver.data.sql.PlayerInfoTable;
import net.sf.l2j.gameserver.data.xml.ItemData;
import net.sf.l2j.gameserver.data.xml.NpcData;
import net.sf.l2j.gameserver.enums.actors.Sex;
import net.sf.l2j.gameserver.model.World;
import net.sf.l2j.gameserver.model.actor.Player;
import net.sf.l2j.gameserver.model.item.instance.ItemInstance;
import net.sf.l2j.gameserver.network.SystemMessageId;
import net.sf.l2j.gameserver.network.serverpackets.SkillList;
import net.sf.l2j.gameserver.network.serverpackets.SystemMessage;
import net.sf.l2j.gameserver.skills.L2Skill;
import com.eclipsesource.json.JsonObject;
/**
* Applies a single Portal deliver_grant payload to the game world.<br>
* <br>
* Outcomes follow the Portal protocol: every grant type requires the target character online —
* an offline target is RETRYABLE_FAILURE / CHARACTER_OFFLINE, and the gateway redelivers once the
* character logs back in. Kept deliberately uniform (no offline-warehouse special case for items)
* so the adapter stays the smallest possible surface to integrate into a pack. The APPLYING marker
* is written to {@link PortalCommandStore} immediately before any world mutation.
*/
public final class GrantApplier
{
private static final CLogger LOGGER = new CLogger(GrantApplier.class.getName());
public record Result(String outcome, boolean durable, String errorCode)
{
static Result applied()
{
return new Result("APPLIED", true, null);
}
static Result retryable(String errorCode)
{
return new Result("RETRYABLE_FAILURE", false, errorCode);
}
/** Declined before touching anything: nothing written, journal included, and a retry
* declines again. The portal releases the player's reservation on it, so it belongs only
* to a check that ran before any mutation. */
static Result refused(String errorCode)
{
return new Result("REFUSED", true, errorCode);
}
static Result permanent(String errorCode)
{
return new Result("PERMANENT_FAILURE", true, errorCode);
}
}
public static Result apply(String commandId, JsonObject payload)
{
final JsonObject target = payload.get("target").asObject();
final String accountName = target.getString("normalizedAccountName", "");
final int characterId = target.getInt("characterId", 0);
final String orderId = payload.getString("orderId", "");
final JsonObject grant = payload.get("grant").asObject();
final String type = grant.getString("type", "");
switch (type)
{
case "l2.grant_item":
return applyToOnlinePlayer(commandId, accountName, characterId, player ->
{
final int itemId = grant.getInt("itemId", 0);
final int count = grant.getInt("count", 0);
if (ItemData.getInstance().getTemplate(itemId) == null)
return refuse(player, "UNKNOWN_ITEM", "that item does not exist on this server");
if (count < 1)
return refuse(player, "INVALID_COUNT", "the item count was invalid");
if (!PortalCommandStore.markApplying(commandId, orderId))
return Result.retryable("STORE_UNAVAILABLE");
// Absent means +0, which is what every grant meant before enchanting existed. A
// stackable item has no enchant level, so the value is simply not applied there.
final int enchantLevel = grant.getInt("enchantLevel", 0);
grantItems(player, itemId, count, enchantLevel);
LOGGER.info("Granted item {} x{} +{} to {} ({}) online.", itemId, count, enchantLevel, player.getName(), accountName);
return Result.applied();
});
case "l2.grant_skill":
return applyToOnlinePlayer(commandId, accountName, characterId, player ->
{
final int skillId = grant.getInt("skillId", 0);
final int skillLevel = grant.getInt("skillLevel", 0);
final L2Skill skill = SkillTable.getInstance().getInfo(skillId, skillLevel);
if (skill == null)
return refuse(player, "UNKNOWN_SKILL", "that skill does not exist on this server");
// Never a downgrade, and never a silent one either: someone who buys level 1 of
// a skill they hold at level 3 keeps level 3, and the portal is told the
// purchase could not be delivered so it gives the coins back. Refused before
// anything is written, journal included — there is nothing to be idempotent
// about when no mutation happened, and the portal releases the reservation on
// the strength of this answer.
final L2Skill known = player.getSkill(skillId);
if (known != null && known.getLevel() >= skillLevel)
{
LOGGER.info("{} already holds skill {} at level {}; refused.", player.getName(), skillId, known.getLevel());
return refuse(player, "SKILL_ALREADY_KNOWN", "you already have that skill at the same level or higher");
}
if (!PortalCommandStore.markApplying(commandId, orderId))
return Result.retryable("STORE_UNAVAILABLE");
player.addSkill(skill, true);
player.sendPacket(new SkillList(player));
// The same line a trainer sends on learning one. Without it a bought skill
// simply exists, and the player has nothing to point at when it does not.
player.sendPacket(SystemMessage.getSystemMessage(SystemMessageId.LEARNED_SKILL_S1).addSkillName(skill));
LOGGER.info("Granted skill {} lv{} to {} ({}).", skillId, skillLevel, player.getName(), accountName);
return Result.applied();
});
case "l2.character_rename":
return applyToOnlinePlayer(commandId, accountName, characterId, player ->
{
final String newName = grant.getString("newName", "");
if (!newName.matches("^[A-Za-z0-9]{1,16}$"))
return refuse(player, "INVALID_CHARACTER_NAME", "that name is not allowed here");
if (NpcData.getInstance().getTemplateByName(newName) != null)
return refuse(player, "RESERVED_CHARACTER_NAME", "that name is reserved here");
final int existingId = PlayerInfoTable.getInstance().getPlayerObjectId(newName);
if (existingId > 0 && existingId != player.getObjectId())
return refuse(player, "CHARACTER_NAME_TAKEN", "that name is already taken");
if (!PortalCommandStore.markApplying(commandId, orderId))
return Result.retryable("STORE_UNAVAILABLE");
player.setName(newName);
PlayerInfoTable.getInstance().updatePlayerData(player, false);
player.store();
player.broadcastUserInfo();
LOGGER.info("Renamed character {} to {} for account {}.", characterId, newName, accountName);
return Result.applied();
});
case "l2.gender_change":
return applyToOnlinePlayer(commandId, accountName, characterId, player ->
{
final String newSex = grant.getString("newSex", "");
if (!"male".equals(newSex) && !"female".equals(newSex))
return refuse(player, "INVALID_CHARACTER_SEX", "that gender was not valid");
// The portal picks the opposite of what it last saw, which is a snapshot: a
// character whose gender changed in game since would be flipped back to where it
// started, for money. Refused before the journal write — nothing touched yet.
final Sex wanted = "male".equals(newSex) ? Sex.MALE : Sex.FEMALE;
if (player.getAppearance().getSex() == wanted)
return refuse(player, "GENDER_UNCHANGED", "your character already has that gender");
if (!PortalCommandStore.markApplying(commandId, orderId))
return Result.retryable("STORE_UNAVAILABLE");
// Tried mirroring the built-in //set sex admin command's decay+respawn
// cycle here (synchronously, no delay) to fix the client showing the old
// model until relog — it caused a client-side General protection fault
// (UNetworkHandler::Tick / UIPacket) instead. Self-decay/respawn of the
// locally-controlled character appears unsafe for the classic client in a
// way that doesn't affect decaying *other* objects/players; reverted to
// the plain appearance update. The client still needs a relog to render
// the new model correctly — cosmetic only, not a crash.
player.getAppearance().setSex("male".equals(newSex) ? Sex.MALE : Sex.FEMALE);
player.store();
player.broadcastUserInfo();
LOGGER.info("Changed gender of character {} to {} for account {}.", characterId, newSex, accountName);
return Result.applied();
});
default:
return Result.refused("UNSUPPORTED_GRANT_TYPE");
}
}
/**
* Puts the purchase in the player's inventory, enchanted if it was bought that way.
* <p>
* addItem returns one {@link ItemInstance} while the container underneath creates one per unit
* for anything that does not stack, so enchanting what it returns enchants a single weapon out
* of five. Anything enchanted is added a unit at a time, and setEnchantLevel takes the owner so
* the client is shown the level rather than being told after the fact — the same call
* {@code //enchant} makes.
*/
private static void grantItems(Player player, int itemId, int count, int enchantLevel)
{
if (enchantLevel <= 0 || ItemData.getInstance().getTemplate(itemId).isStackable())
{
player.addItem(itemId, count, true);
return;
}
for (int i = 0; i < count; i++)
{
// sendMessage false: addItem would say "you picked up 1" and lose the level. Announced
// per object instead, in the form that names it — five weapons are five objects, and
// the level belongs to each one rather than to the purchase.
final ItemInstance created = player.addItem(itemId, 1, false);
if (created == null)
continue;
created.setEnchantLevel(enchantLevel, player);
player.sendPacket(SystemMessage.getSystemMessage(SystemMessageId.OBTAINED_S1_S2).addString(enchantLevel + " ").addItemName(created));
}
}
/**
* Refuses, and tells the player why where they are standing.
* <p>
* Every refusal here happens with the character in the world, so the one place the player is
* certainly looking is the game. Without this the purchase simply does not arrive and the only
* explanation is on a web page they may not open for hours.
*/
private static Result refuse(Player player, String errorCode, String because)
{
player.sendMessage("Portal: your purchase was not delivered - " + because + ".");
return Result.refused(errorCode);
}
private interface OnlineGrant
{
Result apply(Player player);
}
private static Result applyToOnlinePlayer(String commandId, String accountName, int characterId, OnlineGrant grant)
{
final Player player = World.getInstance().getPlayer(characterId);
if (player == null)
return Result.retryable("CHARACTER_OFFLINE");
if (!player.getAccountName().equalsIgnoreCase(accountName))
return Result.refused("CHARACTER_NOT_ON_ACCOUNT");
return grant.apply(player);
}
private GrantApplier()
{
}
}Startup and shutdown
After world and data initialization:
import net.forgeport.connector.PortalConfig;
import net.forgeport.connector.PortalConnector;
StringUtil.printSection("Forgeport");
PortalConfig.load();
PortalConnector.getInstance().start(new AcisGameBridge());During shutdown:
try
{
PortalConnector.getInstance().shutdown();
}
catch (Exception ignored)
{
}After player.spawnMe() in EnterWorld:
import net.forgeport.connector.PortalConnector;
PortalConnector.getInstance().playerOnline(
getClient().getAccountName(),
player.getObjectId()
);portal.properties
Download the file from Admin → Game Servers and place it at
aCis_gameserver/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=-3473Keep this file out of version control.
Build and verify
-
Place
portal-connector-core-<version>.jarandminimal-json-0.9.5.jarinaCis_gameserver/lib/. -
Add the bridge and its game-specific helper classes to the source tree.
-
Build:
cd aCis_gameserver ant dist -
Start the game server and confirm Connected.
-
Run Connector check with a designated online character.