diff --git a/.github/workflows/backport-prs.yml b/.github/workflows/backport-prs.yml new file mode 100644 index 0000000..0e9d05a --- /dev/null +++ b/.github/workflows/backport-prs.yml @@ -0,0 +1,39 @@ +name: Backport merged pull request +on: + pull_request_target: + types: [closed] + issue_comment: + types: [created] +permissions: + contents: write # so it can comment + pull-requests: write # so it can create pull requests +jobs: + backport: + name: Backport pull request + runs-on: ubuntu-latest + + # Only run when pull request is merged + # or when a comment starting with `/backport` is created by someone other than the + # https://github.com/backport-action bot user (user id: 97796249). Note that if you use your + # own PAT as `github_token`, that you should replace this id with yours. + if: > + ( + github.event_name == 'pull_request_target' && + github.event.pull_request.merged + ) || ( + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.user.id != 97796249 && + startsWith(github.event.comment.body, '/backport') + ) + steps: + - uses: actions/checkout@v4 + - name: Create backport pull requests + uses: korthout/backport-action@v3 + with: + github_token: ${{ secrets.TOKEN }} + pull_description: | + Backport of #${pull_number} to `${target_branch}`. + + ### Description + ${pull_description} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ad06a8e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "java.compile.nullAnalysis.mode": "automatic", + "java.completion.importOrder": [ + "", + "javax", + "java", + "#" + ], + "java.sources.organizeImports.starThreshold": 5 +} diff --git a/src/main/java/vip/fubuki/playersync/PlayerSync.java b/src/main/java/vip/fubuki/playersync/PlayerSync.java index 1b5da76..a7eb15e 100644 --- a/src/main/java/vip/fubuki/playersync/PlayerSync.java +++ b/src/main/java/vip/fubuki/playersync/PlayerSync.java @@ -1,6 +1,7 @@ package vip.fubuki.playersync; import com.mojang.logging.LogUtils; +import net.minecraft.SharedConstants; import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.fml.ModList; @@ -50,7 +51,7 @@ public class PlayerSync { String dbName = JdbcConfig.DATABASE_NAME.get(); // Step 1: Create the database using a connection that does not select a database. - JDBCsetUp.executeUpdate("CREATE DATABASE IF NOT EXISTS " + dbName, 1); + JDBCsetUp.executeUpdateWithoutDatabase("CREATE DATABASE IF NOT EXISTS " + dbName); // Step 2: Explicitly select the database on a connection obtained without default database. try (Connection conn = JDBCsetUp.getConnection(false); @@ -112,17 +113,38 @@ public class PlayerSync { "PRIMARY KEY (`id`)" + ");" ); + // do not modify the create table statement to make sure this code is compatible with older database versions + addColumnIfNotExists("server_info", "data_version", "INT NOT NULL DEFAULT 0"); + long current = System.currentTimeMillis(); - JDBCsetUp.executeUpdate( - "INSERT INTO " + dbName + ".server_info(id,enable,last_update) " + - "VALUES(" + JdbcConfig.SERVER_ID.get() + ",true," + current + ") " + - "ON DUPLICATE KEY UPDATE id= " + JdbcConfig.SERVER_ID.get() + ",enable = 1," + - "last_update=" + current + ";" - ); - JDBCsetUp.executeUpdate( - "UPDATE " + dbName + ".server_info SET last_update=" + System.currentTimeMillis() + - " WHERE id='" + JdbcConfig.SERVER_ID.get() + "'" - ); + int data_version = SharedConstants.getCurrentVersion().getDataVersion().getVersion(); + JDBCsetUp.executeUpdate(""" + INSERT INTO %s.server_info + ( + id, + enable, + data_version, + last_update + ) + VALUES ( + %d, + true, + %d, + %d + ) + ON DUPLICATE KEY UPDATE + id = %d, + enable = true, + data_version = %d, + last_update = %d; + """, + dbName, + JdbcConfig.SERVER_ID.get(), + data_version, + current, + JdbcConfig.SERVER_ID.get(), + data_version, + current); // Create curios table if the Curios mod is loaded if (ModList.get().isLoaded("curios")) { @@ -135,28 +157,13 @@ public class PlayerSync { // Create backpack_data table if (ModList.get().isLoaded("sophisticatedbackpacks")) { - JDBCsetUp.executeUpdate( + JDBCsetUp.executeUpdateWithoutDatabase( "CREATE TABLE IF NOT EXISTS " + dbName + ".backpack_data (" + "uuid CHAR(36) NOT NULL, backpack_nbt MEDIUMBLOB, PRIMARY KEY (uuid)" + - ");", 1 + ");" ); - // Check if backpack_data table has the 'uuid' column - JDBCsetUp.QueryResult backpackColCheck = JDBCsetUp.executeQuery( - "SELECT COUNT(*) AS colCount FROM INFORMATION_SCHEMA.COLUMNS " + - "WHERE TABLE_SCHEMA = '" + dbName + "' " + - "AND TABLE_NAME = 'backpack_data' " + - "AND COLUMN_NAME = 'uuid';" - ); - ResultSet rsBackpackCol = backpackColCheck.resultSet(); - if (rsBackpackCol.next() && rsBackpackCol.getInt("colCount") == 0) { - LOGGER.info("Altering backpack_data table to add missing 'uuid' column."); - // Add the missing column and set it as primary key. - JDBCsetUp.executeUpdate("ALTER TABLE " + dbName + ".backpack_data ADD COLUMN uuid CHAR(36) NOT NULL", 1); - JDBCsetUp.executeUpdate("ALTER TABLE " + dbName + ".backpack_data ADD PRIMARY KEY (uuid)", 1); - } - rsBackpackCol.close(); - backpackColCheck.connection().close(); + addColumnIfNotExists("backpack_data", "uuid", "CHAR(36) NOT NULL", true); } // Check and alter the 'advancements' column in player_data if necessary @@ -170,8 +177,8 @@ public class PlayerSync { if (rsAdvCol.next()) { String dataType = rsAdvCol.getString("DATA_TYPE"); if (!"mediumblob".equalsIgnoreCase(dataType)) { - LOGGER.info("Altering player_data table to modify 'advancements' column to MEDIUMBLOB."); - JDBCsetUp.executeUpdate("ALTER TABLE " + dbName + ".player_data MODIFY COLUMN advancements MEDIUMBLOB", 1); + LOGGER.info("Altering player_data table to modify 'advancements' column from {} to MEDIUMBLOB.", dataType); + JDBCsetUp.executeUpdateWithoutDatabase("ALTER TABLE " + dbName + ".player_data MODIFY COLUMN advancements MEDIUMBLOB"); } } rsAdvCol.close(); @@ -185,4 +192,44 @@ public class PlayerSync { ChatSync.shutdown(); } + private static void addColumnIfNotExists(String tableName, String columnName, String dataTypeDefaultNullness, + boolean makePrimaryKey) throws SQLException { + + // Making use of the AutoCloseable QueryResult here + try (JDBCsetUp.QueryResult backpackColCheck = JDBCsetUp.executeQuery( + "SELECT COUNT(*) AS colCount FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_SCHEMA = DATABASE()" + + "AND TABLE_NAME = '" + tableName + "' " + + "AND COLUMN_NAME = '" + columnName + "';")) { + ResultSet rsBackpackCol = backpackColCheck.resultSet(); + + if (!rsBackpackCol.next()) { + LOGGER.warn("Warning: Unable to check existence of colum {} in table {}.", columnName, tableName); + return; + } + + if (rsBackpackCol.getInt("colCount") > 0) { + LOGGER.debug("Column {} already exists. Skipping creation.", columnName); + return; + } + } + + LOGGER.info("ALTER {} table to add missing {} column.", tableName, columnName); + // Add the missing column and set it as primary key. + JDBCsetUp.executeUpdate( + "ALTER TABLE %s ADD COLUMN %s %s", + tableName, columnName, dataTypeDefaultNullness); + + if (makePrimaryKey) { + LOGGER.info("Altering {} table to add primary key on {}.", tableName, columnName); + JDBCsetUp.executeUpdate( + "ALTER TABLE %s ADD PRIMARY KEY (%s)", + tableName, columnName); + } + } + + private static void addColumnIfNotExists(String tableName, String columnName, + String dataTypeDefaultNullness) throws SQLException { + addColumnIfNotExists(tableName, columnName, dataTypeDefaultNullness, false); + } } diff --git a/src/main/java/vip/fubuki/playersync/config/JdbcConfig.java b/src/main/java/vip/fubuki/playersync/config/JdbcConfig.java index fd99bbe..f1b6bb8 100644 --- a/src/main/java/vip/fubuki/playersync/config/JdbcConfig.java +++ b/src/main/java/vip/fubuki/playersync/config/JdbcConfig.java @@ -1,31 +1,32 @@ package vip.fubuki.playersync.config; +import net.neoforged.neoforge.common.ModConfigSpec; + import java.util.ArrayList; import java.util.List; import java.util.Random; -import net.neoforged.neoforge.common.ModConfigSpec; public class JdbcConfig { - public static ModConfigSpec COMMON_CONFIG; - public static ModConfigSpec.ConfigValue HOST; - public static ModConfigSpec.IntValue PORT; - public static ModConfigSpec.ConfigValue USERNAME; - public static ModConfigSpec.ConfigValue PASSWORD; - public static ModConfigSpec.ConfigValue DATABASE_NAME; - public static ModConfigSpec.ConfigValue> SYNC_WORLD; - public static ModConfigSpec.BooleanValue SYNC_ADVANCEMENTS; - public static ModConfigSpec.BooleanValue USE_SSL; - public static ModConfigSpec.BooleanValue SYNC_CHAT; - public static ModConfigSpec.BooleanValue IS_CHAT_SERVER; + public static final ModConfigSpec COMMON_CONFIG; + public static final ModConfigSpec.ConfigValue HOST; + public static final ModConfigSpec.IntValue PORT; + public static final ModConfigSpec.ConfigValue USERNAME; + public static final ModConfigSpec.ConfigValue PASSWORD; + public static final ModConfigSpec.ConfigValue DATABASE_NAME; + public static final ModConfigSpec.ConfigValue> SYNC_WORLD; + public static final ModConfigSpec.BooleanValue SYNC_ADVANCEMENTS; + public static final ModConfigSpec.BooleanValue USE_SSL; + public static final ModConfigSpec.BooleanValue SYNC_CHAT; + public static final ModConfigSpec.BooleanValue IS_CHAT_SERVER; public static final ModConfigSpec.ConfigValue ITEM_PLACEHOLDER_TITLE_OVERRIDE; public static final ModConfigSpec.ConfigValue ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE; - public static ModConfigSpec.ConfigValue CHAT_SERVER_IP; - public static ModConfigSpec.IntValue CHAT_SERVER_PORT; - public static ModConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION; + public static final ModConfigSpec.ConfigValue CHAT_SERVER_IP; + public static final ModConfigSpec.IntValue CHAT_SERVER_PORT; + public static final ModConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION; - public static ModConfigSpec.ConfigValue SERVER_ID; + public static final ModConfigSpec.ConfigValue SERVER_ID; static { diff --git a/src/main/java/vip/fubuki/playersync/sync/ModsSupport.java b/src/main/java/vip/fubuki/playersync/sync/ModsSupport.java index 039167d..3a86cb9 100644 --- a/src/main/java/vip/fubuki/playersync/sync/ModsSupport.java +++ b/src/main/java/vip/fubuki/playersync/sync/ModsSupport.java @@ -6,6 +6,10 @@ import net.minecraft.nbt.NbtUtils; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.ItemStack; import net.neoforged.fml.ModList; +import top.theillusivec4.curios.api.CuriosApi; +import top.theillusivec4.curios.api.type.capability.ICuriosItemHandler; +import top.theillusivec4.curios.api.type.inventory.ICurioStacksHandler; +import top.theillusivec4.curios.api.type.inventory.IDynamicStackHandler; import vip.fubuki.playersync.PlayerSync; import vip.fubuki.playersync.util.JDBCsetUp; import vip.fubuki.playersync.util.LocalJsonUtil; @@ -14,16 +18,9 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.HashMap; import java.util.Map; - -import top.theillusivec4.curios.api.CuriosApi; -import top.theillusivec4.curios.api.type.capability.ICuriosItemHandler; -import top.theillusivec4.curios.api.type.inventory.ICurioStacksHandler; -import top.theillusivec4.curios.api.type.inventory.IDynamicStackHandler; import java.util.Optional; import java.util.UUID; -import static vip.fubuki.playersync.sync.VanillaSync.deserializeString; - public class ModsSupport { @@ -39,14 +36,8 @@ public class ModsSupport { ResultSet rs = qr.resultSet(); if (rs.next()) { String curiosData = rs.getString("curios_item"); - if (curiosData.length() <= 2) { - rs.close(); - qr.connection().close(); - return; - } // Parse the stored data (assumes a simple Map.toString() format: "{key=value, key2=value2, ...}") Map storedMap = LocalJsonUtil.StringToMap(curiosData); - // Clear current Curios slots to avoid conflicts. handlerOpt.ifPresent(handler -> handler.getCurios().forEach((slotType, stacksHandler) -> { // Use the dynamic stack handler to clear slots. @@ -56,6 +47,12 @@ public class ModsSupport { } })); + if (curiosData.length() <= 2) { + rs.close(); + qr.connection().close(); + return; + } + // Restore each saved item. handlerOpt.ifPresent(handler -> { for (Map.Entry entry : storedMap.entrySet()) { @@ -73,9 +70,7 @@ public class ModsSupport { } String serialized = entry.getValue(); try { - String nbtString = VanillaSync.deserializeString(serialized); - CompoundTag tag = NbtUtils.snbtToStructure(nbtString); - ItemStack stack = ItemStack.of(tag); + ItemStack stack = VanillaSync.deserializeAndCreatePlaceholderIfNeeded(serialized); if (handler.getCurios().containsKey(slotType)) { ICurioStacksHandler stacksHandler = handler.getCurios().get(slotType); IDynamicStackHandler dynStacks = stacksHandler.getStacks(); @@ -112,7 +107,7 @@ public class ModsSupport { ResultSet rsBackpack = qrBackpack.resultSet(); if (rsBackpack.next()) { String serialized = rsBackpack.getString("backpack_nbt"); - String nbtString = deserializeString(serialized); + String nbtString = VanillaSync.deserializeString(serialized); CompoundTag backpackNbt = NbtUtils.snbtToStructure(nbtString); // Update BackpackStorage with the retrieved NBT net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get().setBackpackContents(contentsUuid, backpackNbt); diff --git a/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java b/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java index 992ad42..7bc4c10 100644 --- a/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java +++ b/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java @@ -1,7 +1,9 @@ package vip.fubuki.playersync.sync; import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.serialization.Dynamic; import net.minecraft.ChatFormatting; +import net.minecraft.SharedConstants; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.nbt.*; import net.minecraft.network.chat.Component; @@ -10,6 +12,8 @@ import net.minecraft.resources.ResourceLocation; import net.minecraft.server.MinecraftServer; import net.minecraft.server.PlayerAdvancements; import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.datafix.DataFixers; +import net.minecraft.util.datafix.fixes.References; import net.minecraft.world.InteractionHand; import net.minecraft.world.effect.MobEffect; import net.minecraft.world.effect.MobEffectInstance; @@ -247,7 +251,7 @@ public class VanillaSync { } // deserialize item and potentially create placeholders - private static ItemStack deserializeAndCreatePlaceholderIfNeeded(String serializedNbt) + public static ItemStack deserializeAndCreatePlaceholderIfNeeded(String serializedNbt) throws CommandSyntaxException { if (serializedNbt == null || serializedNbt.isEmpty() || serializedNbt.equals("B64:e30=")) { // Check for empty NBT (Base64 encoded '{}') @@ -255,7 +259,7 @@ public class VanillaSync { } String nbtString = deserializeString(serializedNbt); - CompoundTag compoundTag = NbtUtils.snbtToStructure(nbtString); + CompoundTag compoundTag = snbtToFixedCompoundTag(nbtString); if (compoundTag == null || compoundTag.isEmpty() || !compoundTag.contains("id", Tag.TAG_STRING)) { return ItemStack.EMPTY; // Invalid or empty tag @@ -348,6 +352,23 @@ public class VanillaSync { return placeholder; } + public static CompoundTag snbtToFixedCompoundTag(String nbtString) throws CommandSyntaxException { + CompoundTag parsedTag = TagParser.parseTag(nbtString); + + int currentDataVersion = SharedConstants.getCurrentVersion().getDataVersion().getVersion(); + int snbtDataVersion = NbtUtils.getDataVersion(parsedTag, 500); + + Dynamic dynamicTagInput = new Dynamic<>(NbtOps.INSTANCE, parsedTag); + + Dynamic updatedDynamicTag = DataFixers.getDataFixer().update( + References.ITEM_STACK, + dynamicTagInput, + snbtDataVersion, + currentDataVersion); + CompoundTag compoundTag = (CompoundTag) updatedDynamicTag.getValue(); + return compoundTag; + } + /** * 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. @@ -455,6 +476,8 @@ public class VanillaSync { // Serialize the ItemStack to NBT CompoundTag compoundTag = new CompoundTag(); itemStack.save(compoundTag); + // Adding data version to allow newer version of Minecraft to properly update the itemstack from the db + NbtUtils.addCurrentDataVersion(compoundTag); return compoundTag; } @@ -463,7 +486,7 @@ public class VanillaSync { PlayerSync.LOGGER.info("Storing data for player " + player_uuid + " (init=" + init + ")"); // Basic Attributes - int XP = player.totalExperience; + int XP = getTotalExperience(player); int score = player.getScore(); int food_level = player.getFoodData().getFoodLevel(); int health = (int) player.getHealth(); diff --git a/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java b/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java index fea7fd7..033c034 100644 --- a/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java +++ b/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java @@ -1,13 +1,11 @@ package vip.fubuki.playersync.util; +import com.mojang.logging.LogUtils; +import org.slf4j.Logger; import vip.fubuki.playersync.config.JdbcConfig; import java.sql.*; -import org.slf4j.Logger; - -import com.mojang.logging.LogUtils; - public class JDBCsetUp { private static final Logger LOGGER = LogUtils.getLogger(); @@ -45,7 +43,8 @@ public class JDBCsetUp { /** * Executes a query using a connection that includes the database. */ - public static QueryResult executeQuery(String sql) throws SQLException { + public static QueryResult executeQuery(String sqlFormatString, Object... args) throws SQLException { + String sql = String.format(sqlFormatString, args); LOGGER.trace(sql); Connection connection = getConnection(); // With database selected (and "USE" already run) PreparedStatement queryStatement = connection.prepareStatement(sql); @@ -54,28 +53,31 @@ public class JDBCsetUp { } /** - * Executes an update using a connection that includes the database. + * Executes an update using a connection with or without the database within the JDBC URL */ - public static void executeUpdate(String sql) throws SQLException { + private static void executeUpdate(boolean selectDatabase, String sqlFormatString, Object... args) throws SQLException { + String sql = String.format(sqlFormatString, args); LOGGER.trace(sql); - try (Connection connection = getConnection()) { // With database selected + try (Connection connection = getConnection(selectDatabase)) { try (PreparedStatement updateStatement = connection.prepareStatement(sql)) { updateStatement.executeUpdate(); } } } + /** + * Executes an update using a connection that includes the database in the JDBC URL + */ + public static void executeUpdate(String sqlFormatString, Object... args) throws SQLException { + executeUpdate(true, sqlFormatString, args); + } + /** * Executes an update using a connection that does NOT include a default database. * This method is used for commands like "CREATE DATABASE IF NOT EXISTS ..." */ - public static void executeUpdate(String sql, int dummy) throws SQLException { - LOGGER.trace(sql); - try (Connection connection = getConnection(false)) { // Without default database - try (PreparedStatement updateStatement = connection.prepareStatement(sql)) { - updateStatement.executeUpdate(); - } - } + public static void executeUpdateWithoutDatabase(String sqlFormatString, Object... args) throws SQLException { + executeUpdate(false, sqlFormatString, args); } /** @@ -92,6 +94,24 @@ public class JDBCsetUp { } } - public record QueryResult(Connection connection, ResultSet resultSet) { + public record QueryResult(Connection connection, ResultSet resultSet) implements AutoCloseable { + @Override + public void close() { + if (resultSet != null) { + try { + resultSet.close(); + } catch (SQLException e) { + LOGGER.error("Error closing ResultSet", e); + } + } + + if (connection != null) { + try { + connection.close(); + } catch (SQLException e) { + LOGGER.error("Error closing Connection", e); + } + } + } } } diff --git a/src/main/java/vip/fubuki/playersync/util/LocalJsonUtil.java b/src/main/java/vip/fubuki/playersync/util/LocalJsonUtil.java index f82748d..44c2249 100644 --- a/src/main/java/vip/fubuki/playersync/util/LocalJsonUtil.java +++ b/src/main/java/vip/fubuki/playersync/util/LocalJsonUtil.java @@ -2,31 +2,44 @@ package vip.fubuki.playersync.util; import java.util.HashMap; import java.util.Map; +import java.util.function.Function; public class LocalJsonUtil { - public static Map StringToMap(String param) { - Map map = new HashMap<>(); - String s1 = param.substring(1,param.length()-1); - String s2 = s1.trim(); - String[] split = s2.split(","); - for (int i = split.length - 1; i >= 0; i--) { - String trim = split[i].trim(); - String[] split1 = trim.split("="); - map.put(split1[0],split1[1]); + private static Map stringToGenericMap(String param, Function keyParser) { + Map map = new HashMap<>(); + + // check if string is at least minimal json + if (param == null || param.length() < 2 || param.equals("{}")) { + return map; + } + + // extract string within outermost json brackets {} + String s1 = param.substring(param.indexOf('{')+1, param.lastIndexOf('}')).trim(); + if (s1.isEmpty()) { + return map; + } + + // split all json elements + for (String split : s1.split(",")) { + String trim = split.trim(); + + // only check for the first "=" as the values also contain additional "=" + int equalIndex = trim.indexOf('='); + if (equalIndex < 0) + continue; + + String key = trim.substring(0, equalIndex); + String value = trim.substring(equalIndex + 1); + map.put(keyParser.apply(key), value); } return map; } - public static Map StringToEntryMap(String param) { - Map map = new HashMap<>(); - String s1 = param.substring(1,param.length()-1); - String s2 = s1.trim(); - String[] split = s2.split(","); - for (int i = split.length - 1; i >= 0; i--) { - String trim = split[i].trim(); - String[] split1 = trim.split("="); - map.put(Integer.parseInt(split1[0]),split1[1]); - } - return map; + public static Map StringToMap(String param) { + return stringToGenericMap(param, Function.identity()); + } + + public static Map StringToEntryMap(String param) { + return stringToGenericMap(param, Integer::parseInt); } }