diff --git a/src/engine/db/handlers/dbStaticWorldHandler.java b/src/engine/db/handlers/dbStaticWorldHandler.java new file mode 100644 index 00000000..3a045992 --- /dev/null +++ b/src/engine/db/handlers/dbStaticWorldHandler.java @@ -0,0 +1,47 @@ +// • ▌ ▄ ·. ▄▄▄· ▄▄ • ▪ ▄▄· ▄▄▄▄· ▄▄▄· ▐▄▄▄ ▄▄▄ . +// ·██ ▐███▪▐█ ▀█ ▐█ ▀ ▪██ ▐█ ▌▪▐█ ▀█▪▐█ ▀█ •█▌ ▐█▐▌· +// ▐█ ▌▐▌▐█·▄█▀▀█ ▄█ ▀█▄▐█·██ ▄▄▐█▀▀█▄▄█▀▀█ ▐█▐ ▐▌▐▀▀▀ +// ██ ██▌▐█▌▐█ ▪▐▌▐█▄▪▐█▐█▌▐███▌██▄▪▐█▐█ ▪▐▌██▐ █▌▐█▄▄▌ +// ▀▀ █▪▀▀▀ ▀ ▀ ·▀▀▀▀ ▀▀▀·▀▀▀ ·▀▀▀▀ ▀ ▀ ▀▀ █▪ ▀▀▀ +// Magicbane Emulator Project © 2013 - 2026 +// https://www.magicbane.com + +package engine.db.handlers; + +import engine.gameManager.DbManager; +import org.pmw.tinylog.Logger; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; + +public class dbStaticWorldHandler extends dbHandlerBase { + + public dbStaticWorldHandler() { + + } + + // Table and column come from StaticManifest's fixed list, never from input. -1 means "none" and is skipped. + public HashMap GET_NEGATIVE_REFERENCES(String table, String column) { + + HashMap rowsByValue = new HashMap<>(); + + try (Connection connection = DbManager.getConnection(); + PreparedStatement preparedStatement = connection.prepareStatement("SELECT `" + column + "` AS `value`, COUNT(*) AS `rows` FROM `" + + table + "` WHERE `" + column + "` < -1 GROUP BY `" + column + "`;")) { + + ResultSet rs = preparedStatement.executeQuery(); + + while (rs.next()) + rowsByValue.put(rs.getInt("value"), rs.getInt("rows")); + + } catch (SQLException e) { + Logger.error(table + "." + column + ": " + e); + return null; + } + + return rowsByValue; + } +} diff --git a/src/engine/gameManager/DbManager.java b/src/engine/gameManager/DbManager.java index 2efece7f..35012009 100644 --- a/src/engine/gameManager/DbManager.java +++ b/src/engine/gameManager/DbManager.java @@ -69,6 +69,7 @@ public enum DbManager { public static final dbBlueprintHandler BlueprintQueries = new dbBlueprintHandler(); public static final dbShrineHandler ShrineQueries = new dbShrineHandler(); public static final dbRunegateHandler RunegateQueries = new dbRunegateHandler(); + public static final dbStaticWorldHandler StaticWorldQueries = new dbStaticWorldHandler(); public static final dbPetitionHandler PetitionQueries = new dbPetitionHandler(); private static final EnumMap> objectCache = new EnumMap<>(GameObjectType.class); @@ -76,13 +77,16 @@ public enum DbManager { private static HikariDataSource connectionPool = null; // Only the static world factories may call this, in the fixed load order (W2); never renumber. - public static int nextStaticID() { + // The key names the object from the data files alone (findings §5, manifest format) and must be unique. + public static int nextStaticID(GameObjectType objectType, String manifestKey) { int staticID = lastStaticID.decrementAndGet(); if (staticID <= RUNTIME_ID_START) throw new IllegalStateException("Static world ID " + staticID + " reached the runtime ID range at " + RUNTIME_ID_START); + StaticManifest.addEntry(staticID, objectType, manifestKey); + return staticID; } diff --git a/src/engine/gameManager/StaticManifest.java b/src/engine/gameManager/StaticManifest.java new file mode 100644 index 00000000..a94f6f6c --- /dev/null +++ b/src/engine/gameManager/StaticManifest.java @@ -0,0 +1,246 @@ +// • ▌ ▄ ·. ▄▄▄· ▄▄ • ▪ ▄▄· ▄▄▄▄· ▄▄▄· ▐▄▄▄ ▄▄▄ . +// ·██ ▐███▪▐█ ▀█ ▐█ ▀ ▪██ ▐█ ▌▪▐█ ▀█▪▐█ ▀█ •█▌ ▐█▐▌· +// ▐█ ▌▐▌▐█·▄█▀▀█ ▄█ ▀█▄▐█·██ ▄▄▐█▀▀█▄▄█▀▀█ ▐█▐ ▐▌▐▀▀▀ +// ██ ██▌▐█▌▐█ ▪▐▌▐█▄▪▐█▐█▌▐███▌██▄▪▐█▐█ ▪▐▌██▐ █▌▐█▄▄▌ +// ▀▀ █▪▀▀▀ ▀ ▀ ·▀▀▀▀ ▀▀▀·▀▀▀ ·▀▀▀▀ ▀ ▀ ▀▀ █▪ ▀▀▀ +// Magicbane Emulator Project © 2013 - 2026 +// https://www.magicbane.com + +package engine.gameManager; + +import engine.mbEnums.GameObjectType; +import engine.parsers.cache.CacheParserUtil; +import org.json.JSONArray; +import org.json.JSONObject; +import org.pmw.tinylog.Logger; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; + +// W2 safety net: every static ID with its natural key, in load order, written to maps/.manifest at boot. +// Diffing against the previous boot's file shows IDs that moved; the orphan check shows DB references they broke. + +public enum StaticManifest { + + STATICMANIFEST; + + private static final String mapPath = ConfigManager.DEFAULT_DATA_DIR + "maps/"; + + // Player data columns that can hold a static ID (findings §5, Task 2.1). A null type accepts any static object. + private static final String[][] referenceColumns = { + {"object", "parent", null}, + {"obj_character", "char_bindBuilding", "Building"}, + {"obj_character", "guildUID", "Guild"}, + {"dyn_character_guildhistory", "GuildID", "Guild"}, + {"dyn_guild_allianceenemylist", "OtherGuildID", "Guild"}, + {"dyn_guild_guildkoslist", "KOSGuildID", "Guild"}, + {"obj_mob", "mob_buildingID", "Building"}, + {"obj_mob", "mob_guildUID", "Guild"}, + {"obj_npc", "npc_buildingID", "Building"}, + {"obj_npc", "npc_guildID", "Guild"}, + {"obj_city", "treeOfLifeUUID", "Building"}, + {"static_runegate_portals", "sourceBuilding", "Building"}, + {"static_runegate_portals", "targetBuilding", "Building"}, + {"obj_mine", "mine_buildingUID", "Building"} + }; + + private static final ArrayList entries = new ArrayList<>(); + private static final HashMap entriesByID = new HashMap<>(); + private static final HashMap entriesByKey = new HashMap<>(); + + public static void addEntry(int staticID, GameObjectType objectType, String key) { + + ManifestEntry entry = new ManifestEntry(staticID, objectType.name(), key); + + if (entriesByKey.containsKey(key)) + throw new IllegalStateException("Static manifest key " + key + " is used twice"); + + entries.add(entry); + entriesByID.put(staticID, entry); + entriesByKey.put(key, entry); + } + + // Logs moved, vanished and re-targeted IDs against the previous manifest, then replaces it (old copy kept as .prev). + + public static void writeManifest(int mapID) { + + File manifestFile = new File(mapPath + mapID + ".manifest"); + File previousFile = new File(mapPath + mapID + ".manifest.prev"); + HashMap previousByKey; + StringBuilder manifestText = new StringBuilder(); + + previousByKey = readManifest(manifestFile, mapID); + + if (previousByKey != null) + diffManifest(previousByKey); + + manifestText.append("{\n \"map\": ").append(mapID).append(",\n \"count\": ").append(entries.size()) + .append(",\n \"statics\": [\n"); + + for (int i = 0; i < entries.size(); i++) { + manifestText.append(" ").append(entries.get(i).toJson()); + manifestText.append(i < entries.size() - 1 ? ",\n" : "\n"); + } + + manifestText.append(" ]\n}\n"); + + try { + if (manifestFile.exists()) + Files.copy(manifestFile.toPath(), previousFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + + Files.write(manifestFile.toPath(), manifestText.toString().getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + Logger.error("Static manifest not written: " + manifestFile.getAbsolutePath() + ": " + e); + return; + } + + Logger.info("Static manifest: " + entries.size() + " static IDs written to " + manifestFile.getName()); + } + + // Logs every negative DB reference (below -1, which means "none") that no loaded static object of the right type holds. + + public static void logOrphanReferences() { + + HashMap negativeValues; + ManifestEntry entry; + String expectedType; + String problem; + int orphanColumns = 0; + int orphanRows = 0; + + for (String[] referenceColumn : referenceColumns) { + + expectedType = referenceColumn[2]; + negativeValues = DbManager.StaticWorldQueries.GET_NEGATIVE_REFERENCES(referenceColumn[0], referenceColumn[1]); + + if (negativeValues == null) + continue; + + for (Map.Entry negativeValue : negativeValues.entrySet()) { + + entry = entriesByID.get(negativeValue.getKey()); + + if (entry == null) + problem = "no static object"; + else if (DbManager.getFromCache(GameObjectType.valueOf(entry.type), entry.id) == null) + problem = entry.type + " " + entry.key + " is not loaded"; + else if (expectedType != null && !expectedType.equals(entry.type)) + problem = "points at " + entry.type + " " + entry.key + ", expected " + expectedType; + else + continue; + + Logger.warn("Orphan reference: " + referenceColumn[0] + "." + referenceColumn[1] + " = " + + negativeValue.getKey() + " on " + negativeValue.getValue() + " rows: " + problem); + orphanColumns++; + orphanRows += negativeValue.getValue(); + } + } + + Logger.info("Static orphan check: " + orphanColumns + " orphan values on " + orphanRows + " rows"); + } + + private static HashMap readManifest(File manifestFile, int mapID) { + + HashMap previousByKey = new HashMap<>(); + JSONObject manifestJson; + JSONArray staticsJson; + JSONObject entryJson; + + if (!manifestFile.exists()) { + Logger.info("Static manifest: no previous " + manifestFile.getName() + ", nothing to diff"); + return null; + } + + try { + manifestJson = CacheParserUtil.readJsonFile(manifestFile); + + if (manifestJson.getInt("map") != mapID) { + Logger.error(manifestFile.getName() + ": map " + manifestJson.getInt("map") + " does not match MB_WORLD_MAPID " + mapID); + return null; + } + + staticsJson = manifestJson.getJSONArray("statics"); + + for (int i = 0; i < staticsJson.length(); i++) { + entryJson = staticsJson.getJSONObject(i); + previousByKey.put(entryJson.getString("key"), + new ManifestEntry(entryJson.getInt("id"), entryJson.getString("type"), entryJson.getString("key"))); + } + + } catch (Exception e) { + Logger.error("Previous static manifest unreadable, nothing to diff: " + manifestFile.getName() + ": " + e); + return null; + } + + return previousByKey; + } + + // A re-targeted ID is the dangerous case: player references to it now silently point at a different object. + + private static void diffManifest(HashMap previousByKey) { + + HashMap previousByID = new HashMap<>(); + ManifestEntry currentEntry; + ManifestEntry previousEntry; + int moved = 0; + int vanished = 0; + int retargeted = 0; + int added = 0; + + for (ManifestEntry entry : previousByKey.values()) + previousByID.put(entry.id, entry); + + for (ManifestEntry entry : previousByKey.values()) { + + currentEntry = entriesByKey.get(entry.key); + + if (currentEntry == null) { + Logger.warn("Static manifest: " + entry.type + " " + entry.key + " (was " + entry.id + ") vanished"); + vanished++; + } else if (currentEntry.id != entry.id) { + Logger.warn("Static manifest: " + entry.type + " " + entry.key + " moved " + entry.id + " -> " + currentEntry.id); + moved++; + } + } + + for (ManifestEntry entry : entries) { + + previousEntry = previousByID.get(entry.id); + + if (!previousByKey.containsKey(entry.key)) + added++; + + if (previousEntry != null && !previousEntry.key.equals(entry.key)) { + Logger.warn("Static manifest: ID " + entry.id + " re-targeted from " + previousEntry.type + " " + + previousEntry.key + " to " + entry.type + " " + entry.key); + retargeted++; + } + } + + Logger.info("Static manifest diff: " + moved + " moved, " + vanished + " vanished, " + retargeted + + " re-targeted, " + added + " new"); + } + + private static class ManifestEntry { + + private final int id; + private final String type; + private final String key; + + private ManifestEntry(int id, String type, String key) { + this.id = id; + this.type = type; + this.key = key; + } + + private String toJson() { + return "{\"id\": " + id + ", \"type\": " + JSONObject.quote(type) + ", \"key\": " + JSONObject.quote(key) + "}"; + } + } + +} diff --git a/src/engine/objects/Guild.java b/src/engine/objects/Guild.java index 361cb34a..612de00e 100644 --- a/src/engine/objects/Guild.java +++ b/src/engine/objects/Guild.java @@ -176,7 +176,8 @@ public class Guild extends AbstractWorldObject { Guild guild = new Guild(); - guild.setObjectUUID(DbManager.nextStaticID()); + guild.setObjectUUID(DbManager.nextStaticID(GameObjectType.Guild, + "guild:" + Integer.toUnsignedString(seedEntry.guildKeys.get(0)))); guild.setStaticWorldObject(true); guild.name = seedEntry.name; guild.charter = seedEntry.charter; diff --git a/src/engine/server/world/WorldServer.java b/src/engine/server/world/WorldServer.java index e2617bfa..c30cbaed 100644 --- a/src/engine/server/world/WorldServer.java +++ b/src/engine/server/world/WorldServer.java @@ -578,6 +578,9 @@ public class WorldServer { Logger.info("Loading all Mobiles"); DbManager.MobQueries.GET_ALL_MOBS(); + StaticManifest.writeManifest(worldMapID); + StaticManifest.logOrphanReferences(); + Logger.info("time to load World Objects: " + (System.currentTimeMillis() - start) + " ms"); // Configure realms for serialization