Load static trainers from the zone JSON, comparing with the DB until migrated

World Boot Plan 3.2. With MB_STATIC_BUILDINGS=JSON, every TRAINER zone
mobile takes a static ID after the cities (npc:<zone>/m<i>,
npc:<zone>/<prop>/m<i>). While obj_npc still holds trainers of the same
zone and name, the boot only compares and writes
maps/<map>.trainer_migration.sql; afterwards the JSON trainers load
before the DB vendors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MagicBotandClaude Opus 5.5 committed 2026-10-28 08:54:14 -04:00
1 parent 6a3480a0dd
commit 08b50eecf0
4 files changed
+434

No files matched your search

@@ -16,6 +16,7 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.TreeMap;
public class dbStaticWorldHandler extends dbHandlerBase {
@@ -102,4 +103,26 @@ public class dbStaticWorldHandler extends dbHandlerBase {
return -1;
}
// "<parent>/<name>" of every obj_npc row, to tell whether the 3.2 trainer migration has run; null on error.
public HashSet<String> GET_NPC_ZONE_NAMES() {
HashSet<String> zoneNames = new HashSet<>();
try (Connection connection = DbManager.getConnection();
PreparedStatement preparedStatement = connection.prepareStatement("SELECT `object`.`parent`, `obj_npc`.`npc_name` FROM `object` "
+ "INNER JOIN `obj_npc` ON `obj_npc`.`UID` = `object`.`UID`;")) {
ResultSet rs = preparedStatement.executeQuery();
while (rs.next())
zoneNames.add(rs.getInt("parent") + "/" + rs.getString("npc_name"));
} catch (SQLException e) {
Logger.error(e);
return null;
}
return zoneNames;
}
}
@@ -0,0 +1,201 @@
// • ▌ ▄ ·. ▄▄▄· ▄▄ • ▪ ▄▄· ▄▄▄▄· ▄▄▄· ▐▄▄▄ ▄▄▄ .
// ·██ ▐███▪▐█ ▀█ ▐█ ▀ ▪██ ▐█ ▌▪▐█ ▀█▪▐█ ▀█ •█▌ ▐█▐▌·
// ▐█ ▌▐▌▐█·▄█▀▀█ ▄█ ▀█▄▐█·██ ▄▄▐█▀▀█▄▄█▀▀█ ▐█▐ ▐▌▐▀▀▀
// ██ ██▌▐█▌▐█ ▪▐▌▐█▄▪▐█▐█▌▐███▌██▄▪▐█▐█ ▪▐▌██▐ █▌▐█▄▄▌
// ▀▀ █▪▀▀▀ ▀ ▀ ·▀▀▀▀ ▀▀▀·▀▀▀ ·▀▀▀▀ ▀ ▀ ▀▀ █▪ ▀▀▀
// Magicbane Emulator Project © 2013 - 2026
// https://www.magicbane.com
package engine.gameManager;
import engine.gameManager.StaticBuildingLoader.StaticBuildingEntry;
import engine.mbEnums.GameObjectType;
import engine.mbEnums.ZoneMobileType;
import engine.mbEnums.ZonePropType;
import engine.objects.Contract;
import engine.objects.Guild;
import engine.objects.NPC;
import engine.objects.Zone;
import engine.parsers.objects.CZoneMobile;
import engine.parsers.objects.CZoneProp;
import engine.parsers.objects.ZoneTreeEntry;
import org.pmw.tinylog.Logger;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import static engine.util.StringUtils.wordCount;
// World Boot Plan 3.2: one static NPC per TRAINER zone mobile. IDs follow the cities, zone by zone in tree order:
// the zone's own mobiles, then each top-level prop's content mobiles, in JSON order. The mobile index counts
// MOBILE and TRAINER entries alike, so 3.3 can key the mobs without moving these. Never renumber.
public enum StaticTrainerLoader {
STATICTRAINERLOADER;
// Takes the static IDs and resolves contract, building and guild; builds no NPC yet.
public static ArrayList<StaticTrainerEntry> collectTrainers(ArrayList<StaticBuildingEntry> topLevelEntries) {
ArrayList<StaticTrainerEntry> trainerEntries = new ArrayList<>();
HashMap<String, StaticBuildingEntry> buildingsByKey = new HashMap<>();
HashMap<Zone, Guild> cityGuildsByZone = new HashMap<>();
Zone zone;
Guild guild;
CZoneMobile mobile;
CZoneProp prop;
String propKey;
for (StaticBuildingEntry buildingEntry : topLevelEntries) {
buildingsByKey.put(buildingEntry.key, buildingEntry);
if (buildingEntry.prop.prop_type != ZonePropType.CITY)
continue;
guild = GuildManager._npcGuildsByKey.get(buildingEntry.prop.city_prop_gm_guild);
if (guild != null)
cityGuildsByZone.putIfAbsent(ZoneManager.staticZonesByTreeUID.get(buildingEntry.zoneTreeUID), guild);
}
for (ZoneTreeEntry treeEntry : ZoneManager.zoneTree) {
zone = ZoneManager.staticZonesByTreeUID.get(treeEntry.uid);
guild = getZoneGuild(zone, cityGuildsByZone);
for (int mobileIndex = 0; mobileIndex < zone.template.zone_mobile_info.size(); mobileIndex++) {
mobile = zone.template.zone_mobile_info.get(mobileIndex);
if (mobile.mobile_type == ZoneMobileType.TRAINER)
trainerEntries.add(buildEntry("npc:" + treeEntry.uid + "/m" + mobileIndex, zone, mobile, null, guild));
}
for (int propIndex = 0; propIndex < zone.template.zone_prop_info.size(); propIndex++) {
prop = zone.template.zone_prop_info.get(propIndex);
propKey = "prop:" + treeEntry.uid + "/" + propIndex;
for (int mobileIndex = 0; mobileIndex < prop.prop_content_mobiles.size(); mobileIndex++) {
mobile = prop.prop_content_mobiles.get(mobileIndex);
if (mobile.mobile_type != ZoneMobileType.TRAINER)
continue;
// Twins carry no mobiles on map 108 (Task 3.2 check); a trainer in one would lose its building
if (!buildingsByKey.containsKey(propKey))
Logger.error("Static trainers: " + propKey + " was dropped as a twin; trainer " + mobile.mobile_base_zone.basic_zone_name_override + " has no building");
trainerEntries.add(buildEntry("npc:" + treeEntry.uid + "/" + propIndex + "/m" + mobileIndex, zone, mobile,
buildingsByKey.get(propKey), guild));
}
}
}
Logger.info("Static trainers: " + trainerEntries.size() + " TRAINER zone mobiles");
return trainerEntries;
}
private static StaticTrainerEntry buildEntry(String key, Zone zone, CZoneMobile mobile, StaticBuildingEntry buildingEntry, Guild guild) {
StaticTrainerEntry entry = new StaticTrainerEntry();
entry.key = key;
entry.staticID = DbManager.nextStaticID(GameObjectType.NPC, key);
entry.zone = zone;
entry.mobile = mobile;
entry.buildingEntry = buildingEntry;
entry.guild = guild;
entry.contract = findContract(mobile);
if (entry.contract == null)
Logger.warn("Static trainers: " + key + " " + mobile.mobile_base_zone.basic_zone_name_override + " has no contract rune "
+ mobile.mobile_rune_stone_ids + "; it stays out of the world, as the DB row did");
return entry;
}
// The first rune after the race that is a contract (Greer Osrak's is [2], not [1]; findings §6, 3.1)
private static Contract findContract(CZoneMobile mobile) {
Contract contract;
for (int runeIndex = 1; runeIndex < mobile.mobile_rune_stone_ids.size(); runeIndex++) {
contract = DbManager.ContractQueries.GET_CONTRACT(mobile.mobile_rune_stone_ids.get(runeIndex));
if (contract != null)
return contract;
}
return null;
}
// The guild of the zone's first CITY prop, else the nearest parent zone's
private static Guild getZoneGuild(Zone zone, HashMap<Zone, Guild> cityGuildsByZone) {
for (Zone guildZone = zone; guildZone != null; guildZone = guildZone.parent)
if (cityGuildsByZone.containsKey(guildZone))
return cityGuildsByZone.get(guildZone);
return null;
}
// DB NPC rows with a JSON trainer's zone and name: more than 0 means the 3.2 migration hasn't run; -1 on error.
public static int countDbTrainerRows(ArrayList<StaticTrainerEntry> trainerEntries) {
HashSet<String> dbZoneNames = DbManager.StaticWorldQueries.GET_NPC_ZONE_NAMES();
int dbRows = 0;
if (dbZoneNames == null)
return -1;
for (StaticTrainerEntry entry : trainerEntries)
if (dbZoneNames.contains(entry.zone.getObjectUUID() + "/" + entry.mobile.mobile_base_zone.basic_zone_name_override))
dbRows++;
return dbRows;
}
public static void addToWorld(ArrayList<StaticTrainerEntry> trainerEntries) {
for (StaticTrainerEntry entry : trainerEntries) {
entry.npc = NPC.createStaticNPC(entry.staticID, entry.zone, entry.mobile,
entry.contract == null ? 0 : entry.contract.getObjectUUID(),
entry.buildingEntry == null ? null : entry.buildingEntry.building, entry.guild);
DbManager.addToCache(entry.npc);
entry.npc.runAfterLoad();
}
Logger.info("Static trainers: " + trainerEntries.size() + " added to the world");
}
public static class StaticTrainerEntry {
public String key;
public int staticID;
public Zone zone;
public CZoneMobile mobile;
public StaticBuildingEntry buildingEntry;
public Contract contract;
public Guild guild;
public NPC npc;
// The name runAfterLoad gives the NPC
public String getNpcName() {
String name = this.mobile.mobile_base_zone.basic_zone_name_override;
if (wordCount(name) < 2 && this.contract != null)
return name + " the " + this.contract.getName();
return name;
}
}
}
@@ -0,0 +1,183 @@
// • ▌ ▄ ·. ▄▄▄· ▄▄ • ▪ ▄▄· ▄▄▄▄· ▄▄▄· ▐▄▄▄ ▄▄▄ .
// ·██ ▐███▪▐█ ▀█ ▐█ ▀ ▪██ ▐█ ▌▪▐█ ▀█▪▐█ ▀█ •█▌ ▐█▐▌·
// ▐█ ▌▐▌▐█·▄█▀▀█ ▄█ ▀█▄▐█·██ ▄▄▐█▀▀█▄▄█▀▀█ ▐█▐ ▐▌▐▀▀▀
// ██ ██▌▐█▌▐█ ▪▐▌▐█▄▪▐█▐█▌▐███▌██▄▪▐█▐█ ▪▐▌██▐ █▌▐█▄▄▌
// ▀▀ █▪▀▀▀ ▀ ▀ ·▀▀▀▀ ▀▀▀·▀▀▀ ·▀▀▀▀ ▀ ▀ ▀▀ █▪ ▀▀▀
// Magicbane Emulator Project © 2013 - 2026
// https://www.magicbane.com
package engine.gameManager;
import engine.gameManager.StaticTrainerLoader.StaticTrainerEntry;
import engine.math.Vector3fImmutable;
import engine.mbEnums.GameObjectType;
import engine.objects.AbstractGameObject;
import engine.objects.Building;
import engine.objects.Guild;
import engine.objects.Item;
import engine.objects.MobBase;
import engine.objects.NPC;
import org.pmw.tinylog.Logger;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Objects;
import java.util.TreeSet;
// World Boot Plan 3.2: while obj_npc still holds the static trainers, compares them with the TRAINER zone mobiles
// (matched by parent zone and name) and writes maps/<map>.trainer_migration.sql, the DB UID -> static ID mapping
// that sql/task32_trainer_switch.sql deletes the rows by. No JSON NPC is built.
public enum StaticTrainerParity {
STATICTRAINERPARITY;
private static final String mapPath = ConfigManager.DEFAULT_DATA_DIR + "maps/";
private static final float LOCATION_TOLERANCE = 0.2f;
// Run after the DB NPCs load.
public static void run(int mapID, ArrayList<StaticTrainerEntry> trainerEntries) {
HashMap<String, ArrayList<StaticTrainerEntry>> entriesByZoneName = new HashMap<>();
HashMap<NPC, StaticTrainerEntry> entriesByDbNpc = new HashMap<>();
ArrayList<NPC> dbNpcs = new ArrayList<>();
ArrayList<String> differences = new ArrayList<>();
ArrayList<StaticTrainerEntry> candidates;
NPC dbNpc;
int dbOnly = 0;
int jsonOnly = 0;
for (StaticTrainerEntry entry : trainerEntries)
entriesByZoneName.computeIfAbsent(entry.zone.getObjectUUID() + "/" + entry.getNpcName(), key -> new ArrayList<>()).add(entry);
for (AbstractGameObject gameObject : DbManager.getMap(GameObjectType.NPC).values()) {
dbNpc = (NPC) gameObject;
if (!dbNpc.isStaticWorldObject())
dbNpcs.add(dbNpc);
}
dbNpcs.sort((first, second) -> Integer.compare(first.getDBID(), second.getDBID()));
for (NPC npc : dbNpcs) {
candidates = entriesByZoneName.get(npc.parentZoneUUID + "/" + npc.getName());
// The hand-placed vendors match no trainer; they stay in the DB
if (candidates == null || candidates.isEmpty()) {
dbOnly++;
continue;
}
entriesByDbNpc.put(npc, candidates.remove(0));
compareTrainer(npc, entriesByDbNpc.get(npc), differences);
}
for (StaticTrainerEntry entry : trainerEntries)
if (!entriesByDbNpc.containsValue(entry)) {
differences.add("JSON only: " + entry.key + " " + entry.getNpcName());
jsonOnly++;
}
Logger.info("Static trainer parity: " + trainerEntries.size() + " JSON trainers, " + entriesByDbNpc.size() + " matched DB NPCs, "
+ dbOnly + " DB NPCs not trainers (stay in the DB), " + jsonOnly + " JSON only, " + differences.size() + " differences");
for (String difference : differences)
Logger.info(" " + difference);
writeMigration(mapID, dbNpcs, entriesByDbNpc);
}
private static void compareTrainer(NPC dbNpc, StaticTrainerEntry entry, ArrayList<String> differences) {
String label = dbNpc.getDBID() + " " + dbNpc.getName() + " (" + entry.key + ")";
Building jsonBuilding = entry.buildingEntry == null ? null : entry.buildingEntry.building;
Guild jsonGuild;
Vector3fImmutable jsonLoc;
if (jsonBuilding != null)
jsonGuild = jsonBuilding.getGuild();
else
jsonGuild = entry.guild == null ? Guild.getErrantGuild() : entry.guild;
compare(differences, label, "level", (int) dbNpc.level, entry.mobile.mobile_level);
compare(differences, label, "contract", dbNpc.getContractID(), entry.contract == null ? 0 : entry.contract.getObjectUUID());
compare(differences, label, "race", dbNpc.loadID, entry.mobile.mobile_rune_stone_ids.get(0));
compare(differences, label, "runes", NPCManager._runeSetMap.get(dbNpc.runeSetID), entry.mobile.mobile_rune_stone_ids);
compare(differences, label, "building", dbNpc.getBuilding() == null ? 0 : dbNpc.getBuilding().getObjectUUID(),
jsonBuilding == null ? 0 : jsonBuilding.getObjectUUID());
compare(differences, label, "guild", dbNpc.getGuild() == null ? "none" : dbNpc.getGuild().getName(), jsonGuild.getName());
compare(differences, label, "equipment", describeEquipment(dbNpc.charItemManager.equipped.values()),
describeEquipment(MobBase.loadEquipment(entry.mobile.mobile_equipment).values()));
// Building trainers take their spot from the building slot in both loads
if (jsonBuilding == null && dbNpc.getBuilding() == null && dbNpc.getBindLoc() != null) {
jsonLoc = entry.zone.getLoc().add(entry.mobile.mobile_base_zone.basic_zone_spawn_location);
if (dbNpc.getBindLoc().distance(jsonLoc) > LOCATION_TOLERANCE)
differences.add(label + " location: DB " + dbNpc.getBindLoc() + ", JSON " + jsonLoc);
}
}
private static String describeEquipment(Collection<Item> items) {
TreeSet<String> descriptions = new TreeSet<>();
for (Item item : items)
descriptions.add(item.templateID + "@" + item.drop_chance);
return descriptions.toString();
}
private static void compare(ArrayList<String> differences, String label, String field, Object dbValue, Object jsonValue) {
if (!Objects.equals(dbValue, jsonValue))
differences.add(label + " " + field + ": DB " + dbValue + ", JSON " + jsonValue);
}
// task32_trainer_map: every matched DB trainer (UID -> static ID and key).
private static void writeMigration(int mapID, ArrayList<NPC> dbNpcs, HashMap<NPC, StaticTrainerEntry> entriesByDbNpc) {
File migrationFile = new File(mapPath + mapID + ".trainer_migration.sql");
StringBuilder sql = new StringBuilder();
StaticTrainerEntry entry;
int written = 0;
sql.append("-- World Boot Plan 3.2: DB trainer UID -> static ID for map ").append(mapID)
.append(", written by a JSON boot while obj_npc still holds the static trainers.\n");
sql.append("DROP TABLE IF EXISTS `task32_trainer_map`;\n");
sql.append("CREATE TABLE `task32_trainer_map` (`oldUID` BIGINT UNSIGNED NOT NULL, `newID` INT NOT NULL, ")
.append("`staticKey` VARCHAR(64) NOT NULL, PRIMARY KEY (`oldUID`));\n");
sql.append("INSERT INTO `task32_trainer_map` VALUES\n");
for (NPC npc : dbNpcs) {
entry = entriesByDbNpc.get(npc);
if (entry == null)
continue;
sql.append(written == 0 ? "" : ",\n");
sql.append("(").append(npc.getDBID()).append(", ").append(entry.staticID).append(", '").append(entry.key).append("')");
written++;
}
sql.append(";\n");
try {
Files.write(migrationFile.toPath(), sql.toString().getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
Logger.error("Static trainer migration mapping not written: " + migrationFile.getAbsolutePath() + ": " + e);
return;
}
Logger.info("Static trainer migration mapping: " + written + " rows written to " + migrationFile.getName());
}
}
+27
View File
@@ -289,9 +289,11 @@ public class WorldServer {
mbEnums.StaticRunegateMode staticRunegateMode;
int staticZoneRows;
int npcCityRows = 0;
int dbTrainerRows = 0;
int movedStaticIDs;
ArrayList<StaticBuildingLoader.StaticBuildingEntry> staticBuildingEntries = null;
ArrayList<StaticCityLoader.StaticCityEntry> staticCityEntries = null;
ArrayList<StaticTrainerLoader.StaticTrainerEntry> staticTrainerEntries = null;
Logger.info("MAGICBANE SERVER GREETING:");
Logger.info(ConfigManager.MB_WORLD_GREETING.getValue());
@@ -640,6 +642,26 @@ public class WorldServer {
Logger.info("Loading all Cities");
DbManager.CityQueries.GET_ALL_CITIES();
// Until the 3.2 migration deletes the DB trainers, they load from the DB and the JSON trainers only take
// their IDs for the parity check. Static trainers load before the DB vendors, as their UIDs did.
if (staticBuildingMode == mbEnums.StaticBuildingMode.JSON) {
staticTrainerEntries = StaticTrainerLoader.collectTrainers(staticBuildingEntries);
dbTrainerRows = StaticTrainerLoader.countDbTrainerRows(staticTrainerEntries);
if (dbTrainerRows < 0) {
Logger.error("ABORT! DB trainer rows could not be counted.");
exitServer(1);
}
Logger.info("Static trainers: " + dbTrainerRows + " DB NPC rows match a trainer; " + (dbTrainerRows == 0 ? "loading from the zone JSON" : "parity only"));
if (dbTrainerRows == 0)
StaticTrainerLoader.addToWorld(staticTrainerEntries);
}
Logger.info("Loading all NPCs");
DbManager.NPCQueries.GET_ALL_NPCS();
@@ -659,6 +681,11 @@ public class WorldServer {
StaticCityParity.run(worldMapID, staticCityEntries);
}
if (dbTrainerRows > 0) {
Logger.info("Comparing static trainers with the zone JSON");
StaticTrainerParity.run(worldMapID, staticTrainerEntries);
}
movedStaticIDs = StaticManifest.writeManifest(worldMapID);
StaticManifest.logOrphanReferences();