From 99e6dd7817cf6c574c09bde07bd4d20c93253f55 Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Sun, 20 Jul 2025 15:54:35 +0800 Subject: [PATCH 1/4] Add support for importing vanilla playerdata inventories --- build.gradle | 6 + .../commands/PlayerDataImportCommand.java | 74 +++++++++++ .../profile/key/GlobalProfileKey.java | 2 +- .../profile/nbt/PlayerDataExtractor.java | 120 ++++++++++++++++++ 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/mvplugins/multiverse/inventories/commands/PlayerDataImportCommand.java create mode 100644 src/main/java/org/mvplugins/multiverse/inventories/profile/nbt/PlayerDataExtractor.java diff --git a/build.gradle b/build.gradle index a9d231a2..9e064a9c 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,11 @@ repositories { name = 'benwoo1110' url = uri('https://repo.c0ding.party/multiverse-beta') } + + maven { + name = "viaversion" + url = uri("https://repo.viaversion.com") + } } configure(apiDependencies) { @@ -46,6 +51,7 @@ dependencies { shadowed('com.dumptruckman.minecraft:Logging:1.1.1') { exclude group: 'junit', module: 'junit' } + shadowed 'com.viaversion:nbt:5.1.1' // Caching shadowed("com.github.ben-manes.caffeine:caffeine:3.2.0") diff --git a/src/main/java/org/mvplugins/multiverse/inventories/commands/PlayerDataImportCommand.java b/src/main/java/org/mvplugins/multiverse/inventories/commands/PlayerDataImportCommand.java new file mode 100644 index 00000000..8fdce240 --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/inventories/commands/PlayerDataImportCommand.java @@ -0,0 +1,74 @@ +package org.mvplugins.multiverse.inventories.commands; + +import com.google.common.io.Files; +import org.bukkit.Bukkit; +import org.jvnet.hk2.annotations.Service; +import org.mvplugins.multiverse.core.command.MVCommandIssuer; +import org.mvplugins.multiverse.external.acf.commands.annotation.CommandCompletion; +import org.mvplugins.multiverse.external.acf.commands.annotation.CommandPermission; +import org.mvplugins.multiverse.external.acf.commands.annotation.Description; +import org.mvplugins.multiverse.external.acf.commands.annotation.Subcommand; +import org.mvplugins.multiverse.external.acf.commands.annotation.Syntax; +import org.mvplugins.multiverse.external.jakarta.inject.Inject; +import org.mvplugins.multiverse.external.vavr.control.Try; +import org.mvplugins.multiverse.inventories.profile.ProfileDataSource; +import org.mvplugins.multiverse.inventories.profile.data.ProfileData; +import org.mvplugins.multiverse.inventories.profile.key.ContainerType; +import org.mvplugins.multiverse.inventories.profile.key.GlobalProfileKey; +import org.mvplugins.multiverse.inventories.profile.key.ProfileKey; +import org.mvplugins.multiverse.inventories.profile.key.ProfileTypes; +import org.mvplugins.multiverse.inventories.profile.nbt.PlayerDataExtractor; + +import java.io.File; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +@Service +final class PlayerDataImportCommand extends InventoriesCommand { + + private final PlayerDataExtractor playerDataExtractor; + private final ProfileDataSource profileDataSource; + + @Inject + PlayerDataImportCommand(PlayerDataExtractor playerDataExtractor, ProfileDataSource profileDataSource) { + this.playerDataExtractor = playerDataExtractor; + this.profileDataSource = profileDataSource; + } + + @Subcommand("playerdata import") + @Syntax("") + @CommandPermission("multiverse.inventories.importplayerdata") + @CommandCompletion("") + @Description("Import player data from the world's playerdata folder.") + void onCommand(MVCommandIssuer issuer, String world) { + Path worldPath = Bukkit.getWorldContainer().toPath().resolve(world); + File playerDataPath = worldPath.resolve("playerdata").toFile(); + if (!playerDataPath.isDirectory()) { + issuer.sendMessage("World's playerdata folder does not exist: " + world); + return; + } + + List> playerDataFutures = new ArrayList<>(); + for (File playerDataFile : playerDataPath.listFiles()) { + if (!Files.getFileExtension(playerDataFile.getName()).equals("dat")) { + continue; + } + UUID playerUUID = UUID.fromString(Files.getNameWithoutExtension(playerDataFile.getName())); + Try profileData = playerDataExtractor.extract(playerDataFile.toPath()); + playerDataFutures.add(profileDataSource + .getGlobalProfile(GlobalProfileKey.of(playerUUID)) + .thenCompose(profileDataSource::updateGlobalProfile) + .thenCompose(ignore -> profileDataSource.getPlayerProfile( + ProfileKey.of(ContainerType.WORLD, world, ProfileTypes.getDefault(), playerUUID))) + .thenCompose(playerProfile -> { + playerProfile.update(profileData.get()); + return profileDataSource.updatePlayerProfile(playerProfile); + })); + } + CompletableFuture.allOf(playerDataFutures.toArray(new CompletableFuture[0])) + .thenRun(() -> issuer.sendMessage("Successfully imported all player data from " + world + ".")); + } +} diff --git a/src/main/java/org/mvplugins/multiverse/inventories/profile/key/GlobalProfileKey.java b/src/main/java/org/mvplugins/multiverse/inventories/profile/key/GlobalProfileKey.java index 382a09fe..f6d51a2f 100644 --- a/src/main/java/org/mvplugins/multiverse/inventories/profile/key/GlobalProfileKey.java +++ b/src/main/java/org/mvplugins/multiverse/inventories/profile/key/GlobalProfileKey.java @@ -20,7 +20,7 @@ public sealed class GlobalProfileKey permits ProfileFileKey { */ public static GlobalProfileKey of(UUID playerUUID) { return PlayerNamesMapper.getInstance().getKey(playerUUID) - .getOrElse(() -> new GlobalProfileKey(playerUUID, "")); + .getOrElse(() -> new GlobalProfileKey(playerUUID, playerUUID.toString())); } public static GlobalProfileKey of(OfflinePlayer offlinePlayer) { diff --git a/src/main/java/org/mvplugins/multiverse/inventories/profile/nbt/PlayerDataExtractor.java b/src/main/java/org/mvplugins/multiverse/inventories/profile/nbt/PlayerDataExtractor.java new file mode 100644 index 00000000..6b7ba13b --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/inventories/profile/nbt/PlayerDataExtractor.java @@ -0,0 +1,120 @@ +package org.mvplugins.multiverse.inventories.profile.nbt; + +import com.dumptruckman.minecraft.util.Logging; +import com.viaversion.nbt.io.NBTIO; +import com.viaversion.nbt.tag.CompoundTag; +import com.viaversion.nbt.tag.ListTag; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.ApiStatus; +import org.jvnet.hk2.annotations.Service; +import org.mvplugins.multiverse.external.vavr.control.Try; +import org.mvplugins.multiverse.inventories.profile.data.ProfileData; +import org.mvplugins.multiverse.inventories.profile.data.ProfileDataSnapshot; +import org.mvplugins.multiverse.inventories.share.Sharables; +import org.mvplugins.multiverse.inventories.util.PlayerStats; + +import javax.annotation.Nullable; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.util.zip.GZIPOutputStream; + +@ApiStatus.AvailableSince("5.2") +@Service +public final class PlayerDataExtractor { + + @ApiStatus.AvailableSince("5.2") + public Try extract(Path path) { + return Try.of(() -> { + if (!path.toFile().exists()) { + Logging.warning("File %s does not exist! %s", path); + throw new IOException(); + } + Logging.finest("Extracting %s", path); + + CompoundTag playerData = NBTIO + .reader(CompoundTag.class) + .named() + .read(path, true); + + int dataVersion = playerData.getInt("DataVersion"); + Logging.finest("Data version: %s", dataVersion); + CompoundTag equipment = playerData.getCompoundTag("equipment"); + if (equipment == null) { + equipment = new CompoundTag(); + } + + ProfileData profileData = new ProfileDataSnapshot(); + + profileData.set(Sharables.ARMOR, new ItemStack[]{ + extractItem(equipment.getCompoundTag("feet"), dataVersion), + extractItem(equipment.getCompoundTag("legs"), dataVersion), + extractItem(equipment.getCompoundTag("chest"), dataVersion), + extractItem(equipment.getCompoundTag("head"), dataVersion) + }); + // ADVANCEMENTS + // BED_SPAWN + profileData.set(Sharables.ENDER_CHEST, extractItems( + playerData.getListTag("EnderItems", CompoundTag.class), + dataVersion, + PlayerStats.ENDER_CHEST_SIZE + )); + profileData.set(Sharables.EXHAUSTION, playerData.getFloat("foodExhaustionLevel")); + profileData.set(Sharables.EXPERIENCE, playerData.getFloat("XpP")); + profileData.set(Sharables.FALL_DISTANCE, (float) playerData.getDouble("fall_distance")); + profileData.set(Sharables.FIRE_TICKS, (int) playerData.getShort("Fire")); + profileData.set(Sharables.FOOD_LEVEL, playerData.getInt("foodLevel")); + // GAME_STATISTICS + profileData.set(Sharables.HEALTH, (double) playerData.getFloat("Health")); + profileData.set(Sharables.INVENTORY, extractItems( + playerData.getListTag("Inventory", CompoundTag.class), + dataVersion, + PlayerStats.INVENTORY_SIZE + )); + // LAST_LOCATION + profileData.set(Sharables.LEVEL, playerData.getInt("XpLevel")); + // MAXIMUM_AIR + // MAX_HEALTH + profileData.set(Sharables.OFF_HAND, extractItem(equipment.getCompoundTag("offhand"), dataVersion)); + // POTIONS + // RECIPES + profileData.set(Sharables.REMAINING_AIR, (int) playerData.getShort("Air")); + profileData.set(Sharables.SATURATION, playerData.getFloat("foodSaturationLevel")); + profileData.set(Sharables.TOTAL_EXPERIENCE, playerData.getInt("XpTotal")); + + return profileData; + }).onFailure(Throwable::printStackTrace); + } + + private ItemStack[] extractItems(@Nullable ListTag inventoryList, int dataVersion, int inventorySize) throws IOException { + if (inventoryList == null) { + return new ItemStack[inventorySize]; + } + + ItemStack[] items = new ItemStack[inventorySize]; + for (CompoundTag invData : inventoryList) { + int slot = invData.getInt("Slot"); + invData.remove("Slot"); + items[slot] = extractItem(invData, dataVersion); + } + return items; + } + + private @Nullable ItemStack extractItem(@Nullable CompoundTag invData, int dataVersion) throws IOException { + if (invData == null) { + return null; + } + + invData.putInt("DataVersion", dataVersion); + + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + GZIPOutputStream gzipOutputStream = new GZIPOutputStream(byteArrayOutputStream); + NBTIO.writer().named().write(gzipOutputStream, invData); + gzipOutputStream.close(); + byteArrayOutputStream.close(); + + return Try.of(() -> ItemStack.deserializeBytes(byteArrayOutputStream.toByteArray())) + .onFailure(throwable -> Logging.warning("Failed to deserialize item: %s", throwable.getMessage())) + .getOrNull(); + } +} From 17fa61c3a9bae65f1a0d014056a588217352d21d Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Sun, 20 Jul 2025 15:59:31 +0800 Subject: [PATCH 2/4] Fix commands test --- .../java/org/mvplugins/multiverse/inventories/InjectionTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/org/mvplugins/multiverse/inventories/InjectionTest.kt b/src/test/java/org/mvplugins/multiverse/inventories/InjectionTest.kt index 53dad107..ae65cfd2 100644 --- a/src/test/java/org/mvplugins/multiverse/inventories/InjectionTest.kt +++ b/src/test/java/org/mvplugins/multiverse/inventories/InjectionTest.kt @@ -15,7 +15,7 @@ class InjectionTest : TestWithMockBukkit() { @Test fun `InventoriesCommand are available as a service`() { - assertEquals(28, serviceLocator.getAllActiveServices(InventoriesCommand::class.java).size) + assertEquals(29, serviceLocator.getAllActiveServices(InventoriesCommand::class.java).size) } @Test From 680fa2b5feb05fb47ea531603256f97235bf046c Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Fri, 8 Aug 2025 23:08:45 +0800 Subject: [PATCH 3/4] Fix very big jar size due to fastutil dependency --- build.gradle | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index b47d2b42..d5575293 100644 --- a/build.gradle +++ b/build.gradle @@ -46,7 +46,9 @@ dependencies { shadowed('com.dumptruckman.minecraft:Logging:1.1.1') { exclude group: 'junit', module: 'junit' } - shadowed 'com.viaversion:nbt:5.1.1' + shadowed('com.viaversion:nbt:5.1.1') { + exclude group: 'it.unimi.dsi', module: 'fastutil' + } // Caching shadowed("com.github.ben-manes.caffeine:caffeine:3.2.2") From 2bcf8fa7c85ba783dcb54e1f4632bcb352310b5c Mon Sep 17 00:00:00 2001 From: Ben Woo <30431861+benwoo1110@users.noreply.github.com> Date: Fri, 8 Aug 2025 23:10:40 +0800 Subject: [PATCH 4/4] Improve playerdata import tab complete and folder checking --- .../command/MVInvCommandCompletion.java | 11 ++++++++++ .../commands/PlayerDataImportCommand.java | 22 ++++++++++++------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/mvplugins/multiverse/inventories/command/MVInvCommandCompletion.java b/src/main/java/org/mvplugins/multiverse/inventories/command/MVInvCommandCompletion.java index 6826891c..455789f4 100644 --- a/src/main/java/org/mvplugins/multiverse/inventories/command/MVInvCommandCompletion.java +++ b/src/main/java/org/mvplugins/multiverse/inventories/command/MVInvCommandCompletion.java @@ -1,5 +1,7 @@ package org.mvplugins.multiverse.inventories.command; +import org.bukkit.Bukkit; +import org.bukkit.generator.WorldInfo; import org.jetbrains.annotations.NotNull; import org.jvnet.hk2.annotations.Service; import org.mvplugins.multiverse.core.command.MVCommandCompletions; @@ -19,6 +21,7 @@ import org.mvplugins.multiverse.inventories.profile.key.ProfileTypes; import org.mvplugins.multiverse.inventories.share.Sharables; +import java.io.File; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -60,6 +63,7 @@ private MVInvCommandCompletion( commandCompletions.registerAsyncCompletion("shares", this::suggestShares); commandCompletions.registerAsyncCompletion("worldGroups", this::suggestWorldGroups); commandCompletions.registerAsyncCompletion("worldGroupWorlds", this::suggestWorldGroupWorlds); + commandCompletions.registerAsyncCompletion("worldwithplayerdata", this::suggestWorldWithPlayerData); } private Collection suggestDataImporters(BukkitCommandCompletionContext context) { @@ -176,4 +180,11 @@ private Collection suggestWorldGroupWorlds(BukkitCommandCompletionContex return addonToCommaSeperated(context.getInput(), worlds); } + + private Collection suggestWorldWithPlayerData(BukkitCommandCompletionContext context) { + return Bukkit.getWorlds().stream() + .filter(world -> new File(world.getWorldFolder(), "playerdata").isDirectory()) + .map(WorldInfo::getName) + .toList(); + } } diff --git a/src/main/java/org/mvplugins/multiverse/inventories/commands/PlayerDataImportCommand.java b/src/main/java/org/mvplugins/multiverse/inventories/commands/PlayerDataImportCommand.java index 8fdce240..30966164 100644 --- a/src/main/java/org/mvplugins/multiverse/inventories/commands/PlayerDataImportCommand.java +++ b/src/main/java/org/mvplugins/multiverse/inventories/commands/PlayerDataImportCommand.java @@ -1,7 +1,7 @@ package org.mvplugins.multiverse.inventories.commands; import com.google.common.io.Files; -import org.bukkit.Bukkit; +import org.bukkit.World; import org.jvnet.hk2.annotations.Service; import org.mvplugins.multiverse.core.command.MVCommandIssuer; import org.mvplugins.multiverse.external.acf.commands.annotation.CommandCompletion; @@ -41,18 +41,24 @@ final class PlayerDataImportCommand extends InventoriesCommand { @Subcommand("playerdata import") @Syntax("") @CommandPermission("multiverse.inventories.importplayerdata") - @CommandCompletion("") + @CommandCompletion("@worldwithplayerdata") @Description("Import player data from the world's playerdata folder.") - void onCommand(MVCommandIssuer issuer, String world) { - Path worldPath = Bukkit.getWorldContainer().toPath().resolve(world); + void onCommand(MVCommandIssuer issuer, World world) { + Path worldPath = world.getWorldFolder().toPath(); File playerDataPath = worldPath.resolve("playerdata").toFile(); if (!playerDataPath.isDirectory()) { - issuer.sendMessage("World's playerdata folder does not exist: " + world); + issuer.sendMessage("World's playerdata folder does not exist: " + world.getName()); return; } List> playerDataFutures = new ArrayList<>(); - for (File playerDataFile : playerDataPath.listFiles()) { + File[] files = playerDataPath.listFiles(); + if (files == null) { + issuer.sendMessage("No player data files found in the world's playerdata folder: " + world.getName()); + return; + } + + for (File playerDataFile : files) { if (!Files.getFileExtension(playerDataFile.getName()).equals("dat")) { continue; } @@ -62,13 +68,13 @@ void onCommand(MVCommandIssuer issuer, String world) { .getGlobalProfile(GlobalProfileKey.of(playerUUID)) .thenCompose(profileDataSource::updateGlobalProfile) .thenCompose(ignore -> profileDataSource.getPlayerProfile( - ProfileKey.of(ContainerType.WORLD, world, ProfileTypes.getDefault(), playerUUID))) + ProfileKey.of(ContainerType.WORLD, world.getName(), ProfileTypes.getDefault(), playerUUID))) .thenCompose(playerProfile -> { playerProfile.update(profileData.get()); return profileDataSource.updatePlayerProfile(playerProfile); })); } CompletableFuture.allOf(playerDataFutures.toArray(new CompletableFuture[0])) - .thenRun(() -> issuer.sendMessage("Successfully imported all player data from " + world + ".")); + .thenRun(() -> issuer.sendMessage("Successfully imported all player data from " + world.getName() + ".")); } }