package vip.fubuki.playersync.sync; import com.mojang.brigadier.exceptions.CommandSyntaxException; import net.minecraft.ChatFormatting; import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; import net.minecraft.core.HolderLookup; import net.minecraft.core.component.DataComponents; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.NbtUtils; import net.minecraft.nbt.Tag; import net.minecraft.nbt.TagParser; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceLocation; import net.minecraft.server.MinecraftServer; import net.minecraft.server.PlayerAdvancements; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.InteractionHand; import net.minecraft.world.effect.MobEffect; import net.minecraft.world.effect.MobEffectInstance; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; import net.minecraft.world.item.component.CustomData; import net.minecraft.world.item.component.ItemLore; import net.minecraft.world.level.Level; import net.minecraft.world.level.storage.WorldData; import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.fml.ModList; import net.neoforged.fml.common.EventBusSubscriber; import net.neoforged.neoforge.event.OnDatapackSyncEvent; import net.neoforged.neoforge.event.entity.living.LivingDeathEvent; import net.neoforged.neoforge.event.entity.player.PlayerEvent; import net.neoforged.neoforge.event.entity.player.PlayerNegotiationEvent; import net.neoforged.neoforge.event.server.ServerStoppingEvent; import net.neoforged.neoforge.event.tick.ServerTickEvent; import net.neoforged.neoforge.server.ServerLifecycleHooks; import vip.fubuki.playersync.PlayerSync; import vip.fubuki.playersync.config.JdbcConfig; import vip.fubuki.playersync.sync.addons.CuriosCache; import vip.fubuki.playersync.sync.addons.ModCompatSync; import vip.fubuki.playersync.sync.addons.ModsSupport; import vip.fubuki.playersync.util.JDBCsetUp; import vip.fubuki.playersync.util.LocalJsonUtil; import vip.fubuki.playersync.util.PSThreadPoolFactory; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.sql.ResultSet; import java.sql.SQLException; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.locks.ReentrantLock; @EventBusSubscriber(modid = PlayerSync.MODID) public class VanillaSync { public static void register() {} // FIX: Replace unbounded CachedThreadPool with a bounded ThreadPoolExecutor. // CachedThreadPool creates unlimited threads — with many players and slow DB queries, // thread count can explode to 25000+ causing memory leaks and server crashes. // Bounded pool: 2 core threads, max 8 threads, 30s keepalive, 256-task queue. // If the queue is full, tasks run on the calling thread (CallerRunsPolicy) which // provides natural backpressure instead of creating more threads. static ExecutorService executorService = new ThreadPoolExecutor( 2, // core pool size 8, // maximum pool size 30L, TimeUnit.SECONDS, // idle thread keepalive new LinkedBlockingQueue<>(256), // bounded work queue new PSThreadPoolFactory("PlayerSync"), new ThreadPoolExecutor.CallerRunsPolicy() // backpressure: run on caller thread if queue full ); // Per-player locks to prevent concurrent save/restore operations (anti-duplication) private static final ConcurrentHashMap playerLocks = new ConcurrentHashMap<>(); private static ReentrantLock getPlayerLock(String uuid) { return playerLocks.computeIfAbsent(uuid, k -> new ReentrantLock()); } public static void removePlayerLock(String uuid) { playerLocks.remove(uuid); } @SubscribeEvent public static void onDataPackSyncEvent(OnDatapackSyncEvent event) throws SQLException, IOException { if (!JdbcConfig.SYNC_ADVANCEMENTS.get()) return; // advancement sync disabled final ServerPlayer serverPlayer = event.getPlayer(); if (serverPlayer == null) { PlayerSync.LOGGER.debug("No player joining"); return; } final String player_uuid = serverPlayer.getUUID().toString(); PlayerSync.LOGGER.info("Player entity joining level {}", player_uuid); // Use try-with-resources to prevent connection leaks String advancementsData; try (JDBCsetUp.QueryResult advancementsQuery = JDBCsetUp.executePreparedQuery( "SELECT advancements FROM player_data WHERE uuid=?", player_uuid)) { ResultSet advancementsResultSet = advancementsQuery.resultSet(); if (!advancementsResultSet.next()) { PlayerSync.LOGGER.debug("No advancements found for player {}", player_uuid); return; } advancementsData = advancementsResultSet.getString("advancements"); } if (advancementsData == null || advancementsData.length() < 2) { PlayerSync.LOGGER.debug("Skip writing advancements for player {} (empty data)", player_uuid); return; } byte[] bytes = advancementsData.getBytes(StandardCharsets.UTF_8); // Restore Advancements Path path = serverPlayer.getServer().getServerDirectory().resolve(getSyncWorldForServer()); File gameDir = path.toFile(); final MinecraftServer server = ServerLifecycleHooks.getCurrentServer(); if (server.isDedicatedServer()) { PlayerSync.LOGGER.debug("Attempting to write dedicated server advancement file"); File advancements = new File(gameDir, "/advancements" + "/" + player_uuid + ".json"); File advancementsDir = advancements.getParentFile(); if (advancementsDir != null && !advancementsDir.exists()) { PlayerSync.LOGGER.info("Creating advancements directory {}", advancementsDir.getPath()); boolean createdDir = advancementsDir.mkdirs(); if (!createdDir) { PlayerSync.LOGGER.error("Aborting advancements sync. Failed to create advancements directory at {}", advancementsDir.getPath()); return; } } if (!advancements.exists()) { try { PlayerSync.LOGGER.info("Creating new advancement file for player {}", player_uuid); advancements.createNewFile(); } catch (IOException e) { PlayerSync.LOGGER.error("Aborting advancements sync. Failed to create advancements file at {}", advancements.getAbsolutePath(), e); return; } } PlayerSync.LOGGER.debug("Writing advancement file {} for player {}", advancements.toPath(), player_uuid); PlayerSync.LOGGER.trace("Writing advancement file for player {}: {}", player_uuid, new String(bytes, StandardCharsets.UTF_8)); Files.write(advancements.toPath(), bytes); // reload the JSON files on the server after updating them PlayerAdvancements playeradvancements = serverPlayer.getAdvancements(); playeradvancements.reload(server.getAdvancements()); } else { PlayerSync.LOGGER.debug("Writing non-dedicated server advancement files"); File[] files = scanAdvancementsFile(player_uuid, gameDir); for (File file : files) { if (file == null) continue; Files.write(file.toPath(), bytes); } } } public static void doPlayerConnect(PlayerNegotiationEvent event) { try { String player_uuid = event.getProfile().getId().toString(); PlayerSync.LOGGER.info("Detected connection from player {}, starting checking", player_uuid); boolean online; int lastServer; // First query: check basic player data using prepared statement try (JDBCsetUp.QueryResult qr1 = JDBCsetUp.executePreparedQuery( "SELECT online, last_server FROM player_data WHERE uuid=?", player_uuid)) { ResultSet rs1 = qr1.resultSet(); if (!rs1.next()) { PlayerSync.LOGGER.info("A new-player connection detected"); return; } online = rs1.getBoolean("online"); lastServer = rs1.getInt("last_server"); } // Second query: Check if player is already online on another server if (JdbcConfig.KICK_WHEN_ALREADY_ONLINE.get() && online && lastServer != JdbcConfig.SERVER_ID.get()) { try (JDBCsetUp.QueryResult qr2 = JDBCsetUp.executePreparedQuery( "SELECT last_update, enable FROM server_info WHERE id=?", lastServer)) { ResultSet rs2 = qr2.resultSet(); if (rs2.next()) { long last_update = rs2.getLong("last_update"); boolean enable = rs2.getBoolean("enable"); if (enable && System.currentTimeMillis() < last_update + 300000L) { event.getConnection().disconnect(Component.translatableWithFallback("playersync.already_online","You can't join more than one synchronization server at the same time.")); return; } JDBCsetUp.executePreparedUpdate("UPDATE server_info SET enable=0 WHERE id=?", lastServer); } } } } catch (Exception e) { PlayerSync.LOGGER.error("SqlException detected!", e); event.getConnection().disconnect(Component.translatableWithFallback("playersync.sqlexception","SqlException detected!Connection lost,please contact with your admin.")); } } // Use string uuid as key public static Set deadPlayerWhileLogging = ConcurrentHashMap.newKeySet(); public static Set syncNotCompletedPlayer = ConcurrentHashMap.newKeySet(); // Players kicked for being already online on another server - their logout must NOT set online=0 public static Set kickedForDuplicateLogin = ConcurrentHashMap.newKeySet(); public static void doPlayerJoin(PlayerEvent.PlayerLoggedInEvent event) { ServerPlayer serverPlayer = (ServerPlayer) event.getEntity(); String player_uuid = serverPlayer.getUUID().toString(); MinecraftServer server = serverPlayer.getServer(); if (server == null) { PlayerSync.LOGGER.error("Server is null for player {}", player_uuid); return; } if (serverPlayer.isDeadOrDying()) { deadPlayerWhileLogging.add(player_uuid); serverPlayer.removeTag("player_synced"); server.execute(() -> { ResourceKey respawnLevel = serverPlayer.getRespawnDimension(); BlockPos respawnPos = serverPlayer.getRespawnPosition(); if (respawnPos != null) { ServerLevel level = server.getLevel(respawnLevel); if (level != null) { serverPlayer.teleportTo(level, respawnPos.getX(), respawnPos.getY() + 1, respawnPos.getZ(), 0, 0); } } serverPlayer.setHealth(1); serverPlayer.connection.disconnect(Component.translatableWithFallback("playersync.wrong_entity_status","An error occurred while creating playerEntity in the world,please login again.")); }); try { JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get()); JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=1, last_server=? WHERE uuid=?", JdbcConfig.SERVER_ID.get(), player_uuid); } catch (SQLException e) { PlayerSync.LOGGER.error("An error occurred while handling dead/dying player {}", e.getMessage()); } return; } ReentrantLock lock = getPlayerLock(player_uuid); lock.lock(); try { PlayerSync.LOGGER.info("Starting synchronization for player {}", player_uuid); // syncNotCompletedPlayer.add() already done in onPlayerJoin before submit // === PHASE 1: DB reads on background thread (thread-safe) === boolean playerExists; try (JDBCsetUp.QueryResult qr1 = JDBCsetUp.executePreparedQuery( "SELECT uuid FROM player_data WHERE uuid=?", player_uuid)) { playerExists = qr1.resultSet().next(); } if (!playerExists) { // FIX CRITICAL-1/2: online=1 is already set by onPlayerLoggedInKickCheck (synchronous). // Do NOT write online=1 again from background/queued threads - if the player disconnects // quickly, the background write races with logout's online=0 and permanently locks the player. server.execute(() -> { try { new ModsSupport().doCuriosRestore(serverPlayer); store(serverPlayer, true); // INSERT with online=1 handled by store() init path serverPlayer.addTag("player_synced"); } catch (Exception e) { PlayerSync.LOGGER.error("Error initializing new player {}", player_uuid, e); } finally { syncNotCompletedPlayer.remove(player_uuid); } }); return; } // online=1 already set by onPlayerLoggedInKickCheck - no duplicate write here // Read all DB data into local variables (background thread - safe) final int health, foodLevel, xp, score; final String leftHand, cursors, armorData, inventoryData, enderChestData, effectData; try (JDBCsetUp.QueryResult qr2 = JDBCsetUp.executePreparedQuery( "SELECT * FROM player_data WHERE uuid=?", player_uuid)) { ResultSet rs2 = qr2.resultSet(); if (!rs2.next()) { PlayerSync.LOGGER.warn("No data found for existing player {}", player_uuid); syncNotCompletedPlayer.remove(player_uuid); return; } health = rs2.getInt("health"); foodLevel = rs2.getInt("food_level"); xp = rs2.getInt("xp"); score = rs2.getInt("score"); leftHand = rs2.getString("left_hand"); cursors = rs2.getString("cursors"); armorData = rs2.getString("armor"); inventoryData = rs2.getString("inventory"); enderChestData = rs2.getString("enderchest"); effectData = rs2.getString("effects"); } // === PHASE 2: Apply to player on MAIN SERVER THREAD === // Minecraft entities are NOT thread-safe. Modifying inventory/health/effects // from a background thread causes duplication exploits and corruption. CountDownLatch applyLatch = new CountDownLatch(1); server.execute(() -> { try { // ANTI-DUPLICATION: Clear all inventories BEFORE restoring serverPlayer.getInventory().clearContent(); serverPlayer.getEnderChestInventory().clearContent(); serverPlayer.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY); serverPlayer.containerMenu.setCarried(ItemStack.EMPTY); for (int i = 0; i < serverPlayer.getInventory().armor.size(); i++) { serverPlayer.getInventory().armor.set(i, ItemStack.EMPTY); } // Restore basic attributes serverPlayer.setHealth(health <= 0 ? 1 : health); serverPlayer.getFoodData().setFoodLevel(foodLevel); setXpForPlayer(serverPlayer, xp); serverPlayer.setScore(score); // Restore items serverPlayer.setItemInHand(InteractionHand.OFF_HAND, deserializeAndCreatePlaceholderIfNeeded(leftHand)); serverPlayer.containerMenu.setCarried(deserializeAndCreatePlaceholderIfNeeded(cursors)); if (armorData != null && armorData.length() > 2) { Map equipment = LocalJsonUtil.StringToEntryMap(armorData); for (Map.Entry entry : equipment.entrySet()) { serverPlayer.getInventory().armor.set(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue())); } } if (inventoryData != null && inventoryData.length() > 2) { Map inventory = LocalJsonUtil.StringToEntryMap(inventoryData); for (Map.Entry entry : inventory.entrySet()) { serverPlayer.getInventory().setItem(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue())); } } if (enderChestData != null && enderChestData.length() > 2) { Map ender_chest = LocalJsonUtil.StringToEntryMap(enderChestData); for (Map.Entry entry : ender_chest.entrySet()) { serverPlayer.getEnderChestInventory().setItem(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue())); } } // Always clear effects, then restore from DB serverPlayer.removeAllEffects(); if (effectData != null && effectData.length() > 2) { Map effects = LocalJsonUtil.StringToEntryMap(effectData); for (Map.Entry entry : effects.entrySet()) { CompoundTag effectTag = NbtUtils.snbtToStructure(deserializeString(entry.getValue())); MobEffectInstance mobEffectInstance = MobEffectInstance.load(effectTag); if (mobEffectInstance != null) { serverPlayer.addEffect(mobEffectInstance); } } } // Restore mod data (these do their own DB reads internally, acceptable on main thread) ModsSupport modsSupport = new ModsSupport(); modsSupport.doCuriosRestore(serverPlayer); modsSupport.doBackPackRestore(serverPlayer); if (ModList.get().isLoaded("sophisticatedstorage")) { ModsSupport.restoreSophisticatedStorageItems(serverPlayer); } if (ModList.get().isLoaded("refinedstorage")) { ModsSupport.restoreRefinedStorageDisks(serverPlayer); } ModCompatSync.restoreAll(serverPlayer); serverPlayer.addTag("player_synced"); PlayerSync.LOGGER.info("Sync data for player {} completed.", player_uuid); } catch (Exception e) { PlayerSync.LOGGER.error("Error applying sync data for player {}", player_uuid, e); } finally { syncNotCompletedPlayer.remove(player_uuid); applyLatch.countDown(); } }); // FIX H-3: Release lock BEFORE waiting on latch to prevent deadlock. // If we hold the lock while waiting, onServerShutdown trying to acquire // the same lock will deadlock (shutdown blocks main thread, preventing // server.execute() from draining, preventing latch countdown). lock.unlock(); if (!applyLatch.await(60, TimeUnit.SECONDS)) { PlayerSync.LOGGER.error("Timeout waiting for main thread sync for player {}", player_uuid); syncNotCompletedPlayer.remove(player_uuid); } return; // Lock already released, skip finally } catch (Exception e) { PlayerSync.LOGGER.error("Internal Exception detected!", e); syncNotCompletedPlayer.remove(player_uuid); } finally { if (lock.isHeldByCurrentThread()) lock.unlock(); } } @SubscribeEvent public static void onPlayerConnect(PlayerNegotiationEvent event) { // MUST run synchronously to block login until the duplicate check completes. // Running async allowed players to join before the kick check finished. try { doPlayerConnect(event); } catch (Exception e) { PlayerSync.LOGGER.error("Error during player connection check", e); event.getConnection().disconnect(Component.translatableWithFallback("playersync.sqlexception","SqlException detected!Connection lost,please contact with your admin.")); } } /** * FIX: Full duplicate-login kick check during PlayerLoggedInEvent. * PlayerNegotiationEvent.getConnection().disconnect() does NOT reliably disconnect * the player in NeoForge 1.21.1. By the time PlayerLoggedInEvent fires, we have * a full ServerPlayer with player.connection.disconnect() which is reliable. * * Also marks online=1 SYNCHRONOUSLY to close the race condition window. */ @SubscribeEvent(priority = net.neoforged.bus.api.EventPriority.HIGHEST) public static void onPlayerLoggedInKickCheck(PlayerEvent.PlayerLoggedInEvent event) { ServerPlayer player = (ServerPlayer) event.getEntity(); String player_uuid = player.getUUID().toString(); if (!JdbcConfig.KICK_WHEN_ALREADY_ONLINE.get()) { // Still mark online even if kick is disabled try { JDBCsetUp.executePreparedUpdate( "UPDATE player_data SET online=1, last_server=? WHERE uuid=?", JdbcConfig.SERVER_ID.get(), player_uuid); } catch (SQLException ignored) {} return; } try { boolean online = false; int lastServer = 0; try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery( "SELECT online, last_server FROM player_data WHERE uuid=?", player_uuid)) { ResultSet rs = qr.resultSet(); if (rs.next()) { online = rs.getBoolean("online"); lastServer = rs.getInt("last_server"); } } if (online && lastServer != JdbcConfig.SERVER_ID.get()) { // Check if the other server is still alive try (JDBCsetUp.QueryResult qr2 = JDBCsetUp.executePreparedQuery( "SELECT last_update, enable FROM server_info WHERE id=?", lastServer)) { ResultSet rs2 = qr2.resultSet(); if (rs2.next()) { long lastUpdate = rs2.getLong("last_update"); boolean enable = rs2.getBoolean("enable"); if (enable && System.currentTimeMillis() < lastUpdate + 300000L) { // Other server is alive → KICK using ServerPlayer.connection which works reliably // CRITICAL: Mark as kicked BEFORE disconnect so onPlayerLogout does NOT set online=0. // Without this, the logout handler resets online=0, allowing immediate reconnect bypass. kickedForDuplicateLogin.add(player_uuid); PlayerSync.LOGGER.warn("Kicking player {} - already online on server {}", player_uuid, lastServer); player.connection.disconnect(Component.translatableWithFallback( "playersync.already_online", "You can't join more than one synchronization server at the same time.")); return; } // Other server is dead, disable it JDBCsetUp.executePreparedUpdate("UPDATE server_info SET enable=0 WHERE id=?", lastServer); } } } // Mark online=1 SYNCHRONOUSLY JDBCsetUp.executePreparedUpdate( "UPDATE player_data SET online=1, last_server=? WHERE uuid=?", JdbcConfig.SERVER_ID.get(), player_uuid); } catch (Exception e) { PlayerSync.LOGGER.error("Error during kick check for player {}", player_uuid, e); } } @SubscribeEvent public static void onPlayerJoin(PlayerEvent.PlayerLoggedInEvent event) { // FIX: Mark sync as pending BEFORE submitting to thread pool. // Without this, a player who disconnects instantly can trigger onPlayerLogout // before the background thread starts, bypassing the syncNotCompleted guard // and saving invalid entity state. String puuid = ((ServerPlayer) event.getEntity()).getUUID().toString(); syncNotCompletedPlayer.add(puuid); executorService.submit(() -> { try { doPlayerJoin(event); } catch (Exception e) { e.printStackTrace(); syncNotCompletedPlayer.remove(puuid); } }); } // deserialize item and potentially create placeholders public static ItemStack deserializeAndCreatePlaceholderIfNeeded(String serializedNbt) throws CommandSyntaxException { if (serializedNbt == null || serializedNbt.isEmpty() || serializedNbt.equals("B64:e30=")) { // Check for empty NBT (Base64 encoded '{}') return ItemStack.EMPTY; } CompoundTag compoundTag; String nbtString = serializedNbt; // Will be overwritten with decoded SNBT for legacy formats // Try binary NBT format first (new format, avoids SNBT round-trip issues) if (serializedNbt.startsWith("BNBT:")) { try { compoundTag = deserializeBinaryBase64Tag(serializedNbt); } catch (Exception e) { PlayerSync.LOGGER.error("Failed to deserialize binary NBT data, skipping item.", e); return ItemStack.EMPTY; } } else { // Legacy SNBT-based deserialization (B64: or old custom format) nbtString = deserializeString(serializedNbt); try { compoundTag = TagParser.parseTag(nbtString); } catch (CommandSyntaxException e) { // TagParser may fail on certain 1.21.1 component SNBT formats (e.g. nested lists [[{...}]]) // Try NbtUtils.snbtToStructure as a fallback PlayerSync.LOGGER.warn("TagParser.parseTag failed, trying NbtUtils.snbtToStructure fallback. SNBT: {}", nbtString); try { compoundTag = NbtUtils.snbtToStructure(nbtString); } catch (CommandSyntaxException e2) { PlayerSync.LOGGER.error("Both SNBT parsers failed for data: {}", nbtString); throw e; // re-throw original exception } } } if (compoundTag.isEmpty() || !compoundTag.contains("id", Tag.TAG_STRING)) { return ItemStack.EMPTY; // Invalid or empty tag } ResourceLocation registryName = ResourceLocation.tryParse(compoundTag.getString("id")); if (registryName == null) { PlayerSync.LOGGER.warn("Failed to parse registry name from NBT: {}", nbtString); return ItemStack.EMPTY; // Cannot determine item type } if (BuiltInRegistries.ITEM.containsKey(registryName)) { // Item exists (could be vanilla or a loaded mod item), restore normally try { ItemStack restoredItem = ItemStack.parse(ServerLifecycleHooks.getCurrentServer().registryAccess(),compoundTag).get(); // Only return the restored item if the ItemStack.of did not unexpectedly // return an empty item // Either the item is not empty, or it is empty and the original tag was also // empty or it was an empty inventory slot if (!restoredItem.isEmpty() || compoundTag.isEmpty() || registryName.equals(ResourceLocation.tryParse("air"))) { return restoredItem; } // ItemStack.of unexpectedly returned empty for a known, non-air item. PlayerSync.LOGGER.warn( "ItemStack.of returned EMPTY for known item {} with NBT: {}. Creating placeholder as fallback.", registryName, nbtString); } catch (Exception e) { PlayerSync.LOGGER.error( "Error creating ItemStack for known item {} with NBT: {}. Creating placeholder as fallback.", registryName, nbtString, e); } } // Create placeholder PlayerSync.LOGGER.debug("Item {} not found in registry. Creating placeholder.", registryName); ItemStack placeholder = new ItemStack(Items.PAPER); CompoundTag placeholderNbt = new CompoundTag(); // Store the original serialized NBT string, not the parsed CompoundTag string placeholderNbt.putString("playersync:original_item_nbt", serializedNbt); placeholderNbt.putString("playersync:original_item_id", registryName.toString()); // Add a unique UUID to ensure the item is unstackable // Stacked placerholders would be converted into a single item when restoring item placeholderNbt.putUUID("playersync:unique_id", UUID.randomUUID()); CustomData.set(DataComponents.CUSTOM_DATA,placeholder, placeholderNbt); // Add display name and lore String placeholderItemTitleOverride = JdbcConfig.ITEM_PLACEHOLDER_TITLE_OVERRIDE.get(); placeholder.set(DataComponents.ITEM_NAME, Component .literal(!placeholderItemTitleOverride.isBlank() ? placeholderItemTitleOverride : Component.translatable("playersync.item_placeholder_title").getString()) .setStyle(Style.EMPTY.withColor(ChatFormatting.RED).withItalic(true))); List loreList = new ArrayList<>(); String placeholderItemDetails = registryName.toString(); // add a stack size if it is available PlayerSync.LOGGER.warn("Item {}: {}", registryName, compoundTag); int placeholderItemAmount = compoundTag.getInt("Count"); if (placeholderItemAmount > 1) { placeholderItemDetails = placeholderItemAmount + "x " + placeholderItemDetails; } loreList.add( Component.literal(placeholderItemDetails) .setStyle(Style.EMPTY.withColor(ChatFormatting.GRAY).withItalic(false))); // add newline loreList.add(Component.literal("")); String placeholderItemDescriptionOverride = JdbcConfig.ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE.get(); String placeholderItemDescriptionLines = ! placeholderItemDescriptionOverride.isBlank() ? placeholderItemDescriptionOverride : Component.translatable("playersync.item_placeholder_description").getString(); for (String descriptionLine : placeholderItemDescriptionLines.split("\n")) { loreList.add( Component.literal(descriptionLine) .setStyle(Style.EMPTY.withColor(ChatFormatting.DARK_GRAY))); } placeholder.set(DataComponents.LORE,new ItemLore(loreList)); return placeholder; } /** * Deserializes a string from the database back into an NBT string. * Handles both the new Base64 format (prefixed with "B64:") and the old custom format. * * @param encoded The string retrieved from the database. * @return The deserialized NBT string. */ public static String deserializeString(String encoded) { if (encoded.startsWith("B64:")) { String base64 = encoded.substring(4); try { return new String(Base64.getDecoder().decode(base64), StandardCharsets.UTF_8); } catch (IllegalArgumentException ex) { PlayerSync.LOGGER.error("Base64 decoding failed for data: {}", encoded, ex); // fallback to legacy decoding below } } // Legacy fallback using custom replacement // cleanSnbt is applied here because legacy serialization could produce stray {"":""} type markers // B64-decoded data must NOT be cleaned as it contains verbatim NBT from modern mods return LocalJsonUtil.cleanSnbt(encoded.replace("|", ",") .replace("^", "\"") .replace("<", "{") .replace(">", "}") .replace("~", "'")); } /** * Serializes an NBT string for database storage. * Uses Base64 encoding by default (prefixed with "B64:"). * If USE_LEGACY_SERIALIZATION config is true, uses the old custom replacement format. * * @param object The NBT string to serialize. * @return The serialized string. */ public static String serialize(String object) { // Check the config option for backwards compatibility during writing if (JdbcConfig.USE_LEGACY_SERIALIZATION.get()) { // Use old custom replacement logic return object.replace(",", "|") .replace("\"", "^") .replace("{", "<") .replace("}", ">") .replace("'", "~"); } // Base64 encode with a "B64:" marker for new data return "B64:" + Base64.getEncoder().encodeToString(object.getBytes(StandardCharsets.UTF_8)); } /** * FIX CRITICAL (performance): PlayerEvent.SaveToFile fires on the MAIN THREAD * during Minecraft's own autosave cycle (every 6000 ticks) and on player logout. * The previous implementation called store() synchronously, which includes: * - Full inventory serialization * - Multiple JDBC UPDATE/INSERT statements (each one a synchronous network round-trip * to MySQL — 5ms to 4846ms depending on network latency) * With 35 players this caused MSPT spikes of up to 4846ms (97× the 50ms limit). * * NEW APPROACH: * 1. Update server heartbeat ASYNCHRONOUSLY (no main-thread DB call). * 2. If the player has been synced, snapshot all entity state on the main thread * (fast — pure memory serialization, no I/O). * 3. Submit all DB writes to the background executor thread pool. * 4. The main thread NEVER waits for MySQL — it returns immediately. * * Safety: backpack / SophisticatedStorage / RS2 contents are NOT saved here * (they are saved completely on logout and shutdown, which is the correct moment). * The snapshot covers inventory, effects, XP, curios, accessories, cosmetic armor, * and NeoForge attachments — everything that changes frequently during gameplay. */ @SubscribeEvent public static void onPlayerSaveToFile(PlayerEvent.SaveToFile event) { // Always update server heartbeat — async, never blocks main thread executorService.submit(() -> { try { JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get()); } catch (SQLException e) { PlayerSync.LOGGER.error("Error updating server heartbeat on SaveToFile", e); } }); Player player = event.getEntity(); String puuid = player.getUUID().toString(); if (!player.getTags().contains("player_synced")) return; if (syncNotCompletedPlayer.contains(puuid)) return; if (player.isDeadOrDying()) return; // Use tryLock: if a logout save or another SaveToFile save is already writing // this player's data, skip — the other operation already has fresh data. ReentrantLock lock = getPlayerLock(puuid); if (!lock.tryLock()) return; try { // === MAIN THREAD: snapshot all entity state (no DB I/O, pure memory ops) === final PlayerDataSnapshot snapshot = snapshotPlayerData(player); // === BACKGROUND THREAD: all DB writes — main thread continues immediately === executorService.submit(() -> { ReentrantLock bgLock = getPlayerLock(puuid); if (!bgLock.tryLock()) return; // another save started, skip try { writeSnapshotToDB(snapshot); } catch (Exception e) { PlayerSync.LOGGER.error("Error writing async SaveToFile snapshot for player {}", puuid, e); } finally { bgLock.unlock(); } }); } catch (Exception e) { PlayerSync.LOGGER.error("Error snapshotting player {} for SaveToFile", puuid, e); } finally { lock.unlock(); // main thread releases → background thread can now acquire } } @SubscribeEvent public static void onServerShutdown(ServerStoppingEvent event) throws SQLException { // Save ALL online players before shutdown to prevent data loss // Uses ServerStoppingEvent (not ServerStoppedEvent) because players are still connected MinecraftServer server = ServerLifecycleHooks.getCurrentServer(); if (server != null) { for (ServerPlayer player : server.getPlayerList().getPlayers()) { if (player.getTags().contains("player_synced") && !player.isDeadOrDying()) { String puuid = player.getUUID().toString(); // FIX: Acquire per-player lock to prevent race with queued logout save ReentrantLock lock = getPlayerLock(puuid); lock.lock(); try { store(player, false); if (ModList.get().isLoaded("curios")) { new ModsSupport().StoreCurios(player, false); } ModCompatSync.storeAll(player); if (ModList.get().isLoaded("sophisticatedbackpacks")) { ModsSupport.storeSophisticatedBackpacks(player); } if (ModList.get().isLoaded("sophisticatedstorage")) { ModsSupport.storeSophisticatedStorageItems(player); } if (ModList.get().isLoaded("refinedstorage")) { ModsSupport.storeRefinedStorageDisks(player); } PlayerSync.LOGGER.info("Saved player {} data on server shutdown", player.getUUID()); } catch (Exception e) { PlayerSync.LOGGER.error("Error saving player {} on shutdown", player.getUUID(), e); } finally { // CRITICAL: online=0 MUST be in finally - if any save throws, // player gets permanently locked as online=1 try { JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", puuid); } catch (Exception e2) { PlayerSync.LOGGER.error("CRITICAL: Failed to mark player {} offline on shutdown", puuid, e2); } lock.unlock(); } } } } JDBCsetUp.executePreparedUpdate("UPDATE server_info SET enable=0 WHERE id=?", JdbcConfig.SERVER_ID.get()); } /** * FIX C-2: All save operations run on the MAIN THREAD (onPlayerLogout fires on main thread). * Entity state (inventory, curios, effects) is read safely on the correct thread. * DB writes block briefly but this is required for correctness. */ @SubscribeEvent public static void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) { String player_uuid = event.getEntity().getUUID().toString(); // FIX: Players kicked for duplicate login must NOT set online=0. // They are still online on the OTHER server. Setting online=0 here would allow // them to bypass the kick by immediately reconnecting (DB says offline while // they're still on the other server). if (kickedForDuplicateLogin.contains(player_uuid)) { PlayerSync.LOGGER.info("Player {} was kicked for duplicate login, NOT marking offline (still on other server)", player_uuid); kickedForDuplicateLogin.remove(player_uuid); return; } else if (deadPlayerWhileLogging.contains(player_uuid)) { PlayerSync.LOGGER.warn("A dead or dying player was kicked, uuid: {}", player_uuid); try { JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid); } catch (SQLException e) { PlayerSync.LOGGER.error("Error marking dead player offline: {}", player_uuid, e); } deadPlayerWhileLogging.remove(player_uuid); } else if (syncNotCompletedPlayer.contains(player_uuid)) { PlayerSync.LOGGER.warn("Player {} logged out with uncompleted sync. Data won't be saved for safety.", player_uuid); try { JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid); } catch (SQLException e) { PlayerSync.LOGGER.error("Error marking unsynced player offline: {}", player_uuid, e); } syncNotCompletedPlayer.remove(player_uuid); } else { Player player = event.getEntity(); ReentrantLock lock = getPlayerLock(player_uuid); lock.lock(); try { // Save curios (main thread - safe to read Curios API) if (ModList.get().isLoaded("curios")) { ModsSupport modsSupport = new ModsSupport(); if (player.isDeadOrDying()) { modsSupport.saveCuriosFromCacheOrApi(player); } else { modsSupport.onPlayerLeave(player); } } // Save mod compat data (main thread - safe to read Accessories/CosmeticArmor) ModCompatSync.storeAll(player); // Save main inventory + effects + advancements (main thread - safe) store(player, false); } catch (Exception e) { PlayerSync.LOGGER.error("Error during player logout save for {}", player_uuid, e); } finally { // CRITICAL: online=0 MUST be in finally - if store() throws, player gets // permanently locked as online=1 and can never reconnect. try { JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid); } catch (Exception e2) { PlayerSync.LOGGER.error("CRITICAL: Failed to mark player {} offline", player_uuid, e2); } lock.unlock(); removePlayerLock(player_uuid); } } } // Helper function to get the NBT string to be saved // If item is a placeholder, get original NBT; otherwise, get current NBT public static String getNbtForStorage(ItemStack itemStack) { if (itemStack.is(Items.PAPER) && itemStack.getComponents().has(DataComponents.CUSTOM_DATA) && itemStack.getComponents().get(DataComponents.CUSTOM_DATA).contains("playersync:original_item_nbt")) { // It's our placeholder, retrieve the original NBT string return itemStack.getComponents().get(DataComponents.CUSTOM_DATA).copyTag().getString("playersync:original_item_nbt"); } else { // It's a normal item or empty, serialize using binary NBT to avoid SNBT round-trip issues Tag tag = serializeNBT(itemStack); if (tag instanceof CompoundTag compoundTag) { return serializeTagToBinaryBase64(compoundTag); } // Fallback to SNBT-based serialization for non-compound tags return serialize(tag.toString()); } } /** * Serializes a CompoundTag to a Base64-encoded binary NBT string. * This avoids SNBT round-trip issues where Tag.toString() produces SNBT * that TagParser.parseTag() cannot parse back (e.g. with nested lists [[{...}]]). */ public static String serializeTagToBinaryBase64(CompoundTag tag) { try { java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); net.minecraft.nbt.NbtIo.writeCompressed(tag, baos); return "BNBT:" + Base64.getEncoder().encodeToString(baos.toByteArray()); } catch (IOException e) { PlayerSync.LOGGER.error("Failed to serialize NBT to binary, falling back to SNBT", e); return serialize(tag.toString()); } } /** * Deserializes a Base64-encoded binary NBT string back to a CompoundTag. */ public static CompoundTag deserializeBinaryBase64Tag(String encoded) throws IOException { String base64 = encoded.substring(5); // Remove "BNBT:" prefix byte[] bytes = Base64.getDecoder().decode(base64); java.io.ByteArrayInputStream bais = new java.io.ByteArrayInputStream(bytes); return net.minecraft.nbt.NbtIo.readCompressed(bais, net.minecraft.nbt.NbtAccounter.unlimitedHeap()); } public static Tag serializeNBT(ItemStack itemStack) { if (itemStack == null || itemStack.isEmpty()) { return new CompoundTag(); } // Serialize the ItemStack to NBT HolderLookup.Provider provider = ServerLifecycleHooks.getCurrentServer().registryAccess(); Tag compoundTag; compoundTag = itemStack.save(provider); return compoundTag; } public static void store(Player player, boolean init) throws SQLException, IOException { String player_uuid = player.getUUID().toString(); PlayerSync.LOGGER.info("Storing data for player {} (init={})", player_uuid, init); // Basic Attributes int XP = getTotalExperience(player); int score = player.getScore(); int food_level = player.getFoodData().getFoodLevel(); int health = (int) player.getHealth(); // Left Hand String left_hand = getNbtForStorage(player.getItemInHand(InteractionHand.OFF_HAND)); // Cursor String cursors = getNbtForStorage(player.containerMenu.getCarried()); // Equipment (Armor) Map equipment = new HashMap<>(); for (int i = 0; i < player.getInventory().armor.size(); i++) { ItemStack itemStack = player.getInventory().armor.get(i); equipment.put(i, getNbtForStorage(itemStack)); } // Inventory Inventory inventory = player.getInventory(); Map inventoryMap = new HashMap<>(); for (int i = 0; i < inventory.items.size(); i++) { inventoryMap.put(i, getNbtForStorage(inventory.items.get(i))); } // Ender Chest Map ender_chest = new HashMap<>(); for (int i = 0; i < player.getEnderChestInventory().getContainerSize(); i++) { ender_chest.put(i, getNbtForStorage(player.getEnderChestInventory().getItem(i))); } if(ModList.get().isLoaded("sophisticatedbackpacks")){ ModsSupport.storeSophisticatedBackpacks(player); } if(ModList.get().isLoaded("sophisticatedstorage")){ ModsSupport.storeSophisticatedStorageItems(player); } if(ModList.get().isLoaded("refinedstorage")){ ModsSupport.storeRefinedStorageDisks(player); } // Effects Map, MobEffectInstance> effects = player.getActiveEffectsMap(); Map effectMap = new HashMap<>(); for (Map.Entry, MobEffectInstance> entry : effects.entrySet()) { Tag effectTag = entry.getValue().save(); effectMap.put(BuiltInRegistries.MOB_EFFECT.getId(entry.getKey().value()), serialize(effectTag.toString())); } // Advancements File advancements = null; byte[] advancementBytes = new byte[0]; if (JdbcConfig.SYNC_ADVANCEMENTS.get()) { // FIX: Force Minecraft to flush the player's advancements to disk BEFORE reading the file. // Without this, recently earned advancements may not be in the file yet (Minecraft only // flushes advancements during auto-save ~every 5 min). If the player switches servers // before the next auto-save, the stale file is read and new advancements are lost. if (player instanceof ServerPlayer sp) { try { sp.getAdvancements().save(); } catch (Exception e) { PlayerSync.LOGGER.warn("Failed to flush advancements to disk for player {}", player_uuid, e); } } Path path = player.getServer().getServerDirectory().resolve(getSyncWorldForServer()); File gameDir = path.toFile(); final MinecraftServer server = ServerLifecycleHooks.getCurrentServer(); if (server != null && server.isDedicatedServer()) { PlayerSync.LOGGER.trace("Reading dedicated server advancements"); advancements = new File(gameDir,"/advancements" + "/" + player_uuid + ".json"); } else { gameDir = Objects.requireNonNull(player.getServer()).getServerDirectory().toFile(); PlayerSync.LOGGER.debug("Reading non-dedicated server advancements"); File[] files = scanAdvancementsFile(player_uuid, gameDir); long latestModifiedDate = 0; for (File file : files) { if (file == null) continue; if (file.lastModified() > latestModifiedDate) { latestModifiedDate = file.lastModified(); advancements = file; } } } // FIX: Null safety - advancements file may be null if no files were found if (advancements != null && advancements.exists()) { PlayerSync.LOGGER.debug("Storing advancements for {} from {}", player_uuid, advancements.toPath()); advancementBytes = Files.readAllBytes(advancements.toPath()); } else { PlayerSync.LOGGER.warn("Unable to save advancements for player {} (file not found)", player_uuid); } } String json = new String(advancementBytes, StandardCharsets.UTF_8); PlayerSync.LOGGER.trace("Storing advancements for player {}: {}", player_uuid, json); // SQL Operation for player data - using prepared statements to prevent // SQL injection and data corruption from special characters (especially in advancement JSON) if (init) { JDBCsetUp.executePreparedUpdate( "INSERT INTO player_data (uuid, armor, inventory, enderchest, advancements, effects, xp, food_level, health, score, left_hand, cursors, online) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)", player_uuid, equipment.toString(), inventoryMap.toString(), ender_chest.toString(), json, effectMap.toString(), XP, food_level, health, score, left_hand, cursors); } else { JDBCsetUp.executePreparedUpdate( "UPDATE player_data SET inventory=?, armor=?, xp=?, effects=?, enderchest=?, score=?, food_level=?, health=?, advancements=?, left_hand=?, cursors=? WHERE uuid=?", inventoryMap.toString(), equipment.toString(), XP, effectMap.toString(), ender_chest.toString(), score, food_level, health, json, left_hand, cursors, player_uuid); } } /** * Immutable snapshot of all player data, captured on the main thread. * Can be safely passed to a background thread for DB writes. */ record PlayerDataSnapshot( String uuid, int xp, int score, int foodLevel, int health, String leftHand, String cursors, String equipment, String inventory, String enderChest, String effects, String advancements, // Mod data snapshots (serialized strings, thread-safe) String curiosData, String accessoriesData, String cosmeticArmorData, String attachmentsData ) {} /** * Captures all player data into an immutable snapshot on the MAIN THREAD. * This is fast (no DB I/O, just serialization to strings). */ private static PlayerDataSnapshot snapshotPlayerData(Player player) throws Exception { String uuid = player.getUUID().toString(); int XP = getTotalExperience(player); int score = player.getScore(); int foodLevel = player.getFoodData().getFoodLevel(); int health = (int) player.getHealth(); String leftHand = getNbtForStorage(player.getItemInHand(net.minecraft.world.InteractionHand.OFF_HAND)); String cursors = getNbtForStorage(player.containerMenu.getCarried()); Map equipmentMap = new HashMap<>(); for (int i = 0; i < player.getInventory().armor.size(); i++) { equipmentMap.put(i, getNbtForStorage(player.getInventory().armor.get(i))); } Map inventoryMap = new HashMap<>(); for (int i = 0; i < player.getInventory().items.size(); i++) { inventoryMap.put(i, getNbtForStorage(player.getInventory().items.get(i))); } Map enderChestMap = new HashMap<>(); for (int i = 0; i < player.getEnderChestInventory().getContainerSize(); i++) { enderChestMap.put(i, getNbtForStorage(player.getEnderChestInventory().getItem(i))); } Map effectMap = new HashMap<>(); for (Map.Entry, MobEffectInstance> entry : player.getActiveEffectsMap().entrySet()) { Tag effectTag = entry.getValue().save(); effectMap.put(BuiltInRegistries.MOB_EFFECT.getId(entry.getKey().value()), serialize(effectTag.toString())); } // Advancements (file read, fast) String advancements = ""; if (JdbcConfig.SYNC_ADVANCEMENTS.get() && player instanceof ServerPlayer sp) { try { sp.getAdvancements().save(); } catch (Exception ignored) {} Path path = sp.getServer().getServerDirectory().resolve(getSyncWorldForServer()); File advFile = new File(path.toFile(), "/advancements/" + uuid + ".json"); if (advFile.exists()) { advancements = new String(Files.readAllBytes(advFile.toPath()), StandardCharsets.UTF_8); } } // Mod data snapshots — entity reads, MUST be on main thread. // These are included in the snapshot so the background writer can persist them // without touching the entity again. String curiosData = ModList.get().isLoaded("curios") && !player.isDeadOrDying() ? ModsSupport.snapshotCuriosData(player) : null; String accessoriesData = ModCompatSync.snapshotAccessories(player); String cosmeticArmorData = ModCompatSync.snapshotCosmeticArmor(player); String attachmentsData = ModCompatSync.snapshotAttachments(player); // NOTE: Sophisticated Backpacks/Storage/RS2 saves are intentionally NOT in the // periodic snapshot — their contents live in server-side SavedData and are // always saved completely on logout / server shutdown. return new PlayerDataSnapshot( uuid, XP, score, foodLevel, health, leftHand, cursors, equipmentMap.toString(), inventoryMap.toString(), enderChestMap.toString(), effectMap.toString(), advancements, curiosData, accessoriesData, cosmeticArmorData, attachmentsData ); } /** * Writes a snapshot to the DB. Runs on BACKGROUND THREAD — no entity access. * All data (basic + curios + mod compat) is written here in one pass. */ private static void writeSnapshotToDB(PlayerDataSnapshot s) throws Exception { // Core player data JDBCsetUp.executePreparedUpdate( "UPDATE player_data SET inventory=?, armor=?, xp=?, effects=?, enderchest=?, score=?, food_level=?, health=?, advancements=?, left_hand=?, cursors=? WHERE uuid=?", s.inventory(), s.equipment(), s.xp(), s.effects(), s.enderChest(), s.score(), s.foodLevel(), s.health(), s.advancements(), s.leftHand(), s.cursors(), s.uuid()); // Curios (snapshotted on main thread, written here off-thread) if (s.curiosData() != null) { JDBCsetUp.executePreparedUpdate( "REPLACE INTO curios (uuid, curios_item) VALUES (?, ?)", s.uuid(), s.curiosData()); } // Mod compat: Accessories + CosmeticArmor + NeoForge attachments ModCompatSync.writeModSnapshot(s.uuid(), s.accessoriesData(), s.cosmeticArmorData(), s.attachmentsData()); } private static String getSyncWorldForServer() { if (!JdbcConfig.SYNC_WORLD.get().isEmpty()) { PlayerSync.LOGGER.warn("Using configuration 'sync_world' on servers is deprecated. Please leave the array empty. Falling back to first entry."); return JdbcConfig.SYNC_WORLD.get().getFirst(); } final MinecraftServer server = ServerLifecycleHooks.getCurrentServer(); if (server == null) { PlayerSync.LOGGER.error("Unable to get current server. Assuming default level-name 'world'."); return "world"; } final WorldData worldData = server.getWorldData(); final String levelName = worldData.getLevelName(); PlayerSync.LOGGER.debug("Using server level-name: {}", levelName); return levelName; } private static File[] scanAdvancementsFile(String player_uuid, File gameDir) { File[] files = new File[JdbcConfig.SYNC_WORLD.get().size()]; for (int i = 0; i < JdbcConfig.SYNC_WORLD.get().size(); i++) { File advanceFile = new File(gameDir, "saves/" + JdbcConfig.SYNC_WORLD.get().get(i) + "/advancements" + "/" + player_uuid + ".json"); if (!advanceFile.exists()) continue; files[i] = advanceFile; } return files; } // All periodic tasks merged into a single ServerTickEvent handler. // FIX: Previously used LevelTickEvent which fires once per dimension, causing the tick counter // to increment 3x faster than expected (once per overworld, nether, end). private static int heartbeatTickCounter = 0; private static final int HEARTBEAT_INTERVAL_TICKS = 600; // Every 30 seconds (20 tps * 30s) private static int autoSaveTickCounter = 0; private static final int AUTO_SAVE_INTERVAL_TICKS = 2400; // Every 2 minutes (was 1min, doubled to reduce main thread load) private static int autoCleanCuriosCacheTickCounter = 0; private static final int AUTO_CLEAN_CURIOS_CACHE_INTERVAL_TICKS = 36000; // Every 30 min @SubscribeEvent public static void onServerTick(ServerTickEvent.Post event) { heartbeatTickCounter++; autoSaveTickCounter++; autoCleanCuriosCacheTickCounter++; // Heartbeat: update server_info to prove this server is alive if (heartbeatTickCounter >= HEARTBEAT_INTERVAL_TICKS) { heartbeatTickCounter = 0; executorService.submit(() -> { try { JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get()); } catch (SQLException e) { PlayerSync.LOGGER.error("Error updating server heartbeat", e); } }); } // Auto-save: snapshot ALL entity data on MAIN THREAD (fast, no I/O), then write // to DB on a BACKGROUND THREAD. // // FIX: Previously the background task called ModCompatSync.storeAll(player), // storeSophisticatedBackpacks(player), etc. from off-thread — accessing entity // state (inventory, Accessories API, CosmeticArmor, NeoForge attachments) in a // non-thread-safe way. All entity reads are now done in snapshotPlayerData() // on the main thread, and the background task only does DB writes. // // Backpack / SophisticatedStorage / RS2 contents live in server-side SavedData // and are always saved completely on player logout + server shutdown — no need // to include them in the periodic auto-save. if (autoSaveTickCounter >= AUTO_SAVE_INTERVAL_TICKS) { autoSaveTickCounter = 0; MinecraftServer server = ServerLifecycleHooks.getCurrentServer(); if (server != null) { for (ServerPlayer player : server.getPlayerList().getPlayers()) { if (player.isDeadOrDying() || syncNotCompletedPlayer.contains(player.getUUID().toString())) { continue; } String puuid = player.getUUID().toString(); ReentrantLock lock = getPlayerLock(puuid); if (!lock.tryLock()) continue; try { // === MAIN THREAD: snapshot ALL entity state (no DB I/O) === // snapshotPlayerData now includes curios, accessories, // cosmeticarmor, and neoforge attachments. final PlayerDataSnapshot snapshot = snapshotPlayerData(player); // === BACKGROUND THREAD: DB writes only (no entity access) === executorService.submit(() -> { ReentrantLock bgLock = getPlayerLock(puuid); if (!bgLock.tryLock()) return; try { writeSnapshotToDB(snapshot); } catch (Exception e) { PlayerSync.LOGGER.error("Error auto-saving player {}", puuid, e); } finally { bgLock.unlock(); } }); } catch (Exception e) { PlayerSync.LOGGER.error("Error snapshotting player {}", puuid, e); } finally { lock.unlock(); } } } } // Clean expired curios cache if (autoCleanCuriosCacheTickCounter >= AUTO_CLEAN_CURIOS_CACHE_INTERVAL_TICKS) { autoCleanCuriosCacheTickCounter = 0; executorService.submit(() -> { try { CuriosCache.RemoveExpiredCuriosCache(); } catch (Exception e) { PlayerSync.LOGGER.error("An error occurred while cleaning curios cache: {}", e.getMessage()); } }); } } private static void setXpForPlayer(ServerPlayer serverPlayer, int databaseXp) { // Don't use giveExperience() as it has several side-effects: // triggers an event, sends network packets, increases the score, ... serverPlayer.totalExperience = databaseXp; serverPlayer.experienceLevel = 0; serverPlayer.experienceProgress = 0; int xpForLevel; while (databaseXp >= (xpForLevel = serverPlayer.getXpNeededForNextLevel())) { databaseXp -= xpForLevel; serverPlayer.experienceLevel++; } serverPlayer.experienceProgress = serverPlayer.experienceLevel > 0 ? (float) databaseXp / serverPlayer.getXpNeededForNextLevel() : 0f; PlayerSync.LOGGER.debug("Giving player {} levels and {}% experience progress, calculated from {} XP.", serverPlayer.experienceLevel, serverPlayer.experienceProgress * 100, serverPlayer.totalExperience); } private static int getTotalExperience(final Player player) { int level = player.experienceLevel; int totalXp = 0; // Calculate total XP for completed levels if (level > 30) { totalXp = (int) (4.5 * Math.pow(level, 2) - 162.5 * level + 2220); } else if (level > 15) { totalXp = (int) (2.5 * Math.pow(level, 2) - 40.5 * level + 360); } else { totalXp = level * level + 6 * level; } // Add partial level progress totalXp += Math.round(player.getXpNeededForNextLevel() * player.experienceProgress); PlayerSync.LOGGER.debug("Experience calcuation for {} levels and {}% experience progress yields {} XP.", player.experienceLevel, player.experienceProgress * 100, totalXp); return totalXp; } @SubscribeEvent //Don't know what will happen if a fake player is killed,need more test. public static void onPlayerDeath(LivingDeathEvent event) { if (event.getEntity() instanceof ServerPlayer player && !deadPlayerWhileLogging.contains(event.getEntity().getUUID().toString())) { CuriosCache.tryStoreCuriosToCache(player); } } }