diff --git a/build.gradle b/build.gradle index 83ca65ed..d5575293 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) { @@ -41,6 +46,9 @@ dependencies { shadowed('com.dumptruckman.minecraft:Logging:1.1.1') { exclude group: 'junit', module: 'junit' } + 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") 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 new file mode 100644 index 00000000..30966164 --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/inventories/commands/PlayerDataImportCommand.java @@ -0,0 +1,80 @@ +package org.mvplugins.multiverse.inventories.commands; + +import com.google.common.io.Files; +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; +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("@worldwithplayerdata") + @Description("Import player data from the world's playerdata folder.") + 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.getName()); + return; + } + + List> playerDataFutures = new ArrayList<>(); + 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; + } + 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.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.getName() + ".")); + } +} 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(); + } +} diff --git a/src/test/java/org/mvplugins/multiverse/inventories/InjectionTest.kt b/src/test/java/org/mvplugins/multiverse/inventories/InjectionTest.kt index dd0add30..6c4b1baa 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(30, serviceLocator.getAllActiveServices(InventoriesCommand::class.java).size) + assertEquals(31, serviceLocator.getAllActiveServices(InventoriesCommand::class.java).size) } @Test