diff --git a/bukkit/src/main/java/net/william278/husksync/BukkitHuskSync.java b/bukkit/src/main/java/net/william278/husksync/BukkitHuskSync.java index c4303063..f8686c64 100644 --- a/bukkit/src/main/java/net/william278/husksync/BukkitHuskSync.java +++ b/bukkit/src/main/java/net/william278/husksync/BukkitHuskSync.java @@ -56,7 +56,7 @@ import net.william278.husksync.sync.DataSyncer; import net.william278.husksync.user.BukkitUser; import net.william278.husksync.user.OnlineUser; import net.william278.husksync.util.BukkitLegacyConverter; -import net.william278.husksync.util.BukkitMapPersister; +import net.william278.husksync.maps.BukkitMapHandler; import net.william278.husksync.util.BukkitTask; import net.william278.husksync.util.LegacyConverter; import net.william278.toilet.BukkitToilet; @@ -84,7 +84,7 @@ import java.util.stream.Collectors; @NoArgsConstructor @SuppressWarnings("unchecked") public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.Supplier, - BukkitEventDispatcher, BukkitMapPersister { + BukkitEventDispatcher, BukkitMapHandler { /** * Metrics ID for HuskSync on Bukkit. diff --git a/bukkit/src/main/java/net/william278/husksync/data/BukkitUserDataHolder.java b/bukkit/src/main/java/net/william278/husksync/data/BukkitUserDataHolder.java index 9eb93aaf..f6dcacb7 100644 --- a/bukkit/src/main/java/net/william278/husksync/data/BukkitUserDataHolder.java +++ b/bukkit/src/main/java/net/william278/husksync/data/BukkitUserDataHolder.java @@ -20,7 +20,7 @@ package net.william278.husksync.data; import net.william278.husksync.BukkitHuskSync; -import net.william278.husksync.util.BukkitMapPersister; +import net.william278.husksync.maps.BukkitMapHandler; import org.bukkit.entity.Player; import org.bukkit.inventory.PlayerInventory; import org.jetbrains.annotations.NotNull; @@ -163,7 +163,7 @@ public interface BukkitUserDataHolder extends UserDataHolder { } @NotNull - default BukkitMapPersister getMapPersister() { + default BukkitMapHandler getMapPersister() { return (BukkitHuskSync) getPlugin(); } diff --git a/bukkit/src/main/java/net/william278/husksync/listener/BukkitEventListener.java b/bukkit/src/main/java/net/william278/husksync/listener/BukkitEventListener.java index f00ca273..743b3736 100644 --- a/bukkit/src/main/java/net/william278/husksync/listener/BukkitEventListener.java +++ b/bukkit/src/main/java/net/william278/husksync/listener/BukkitEventListener.java @@ -135,7 +135,7 @@ public class BukkitEventListener extends EventListener implements BukkitJoinEven @EventHandler(ignoreCancelled = true) public void onMapInitialize(@NotNull MapInitializeEvent event) { if (plugin.getSettings().getSynchronization().isPersistLockedMaps() && event.getMap().isLocked()) { - getPlugin().runAsync(() -> ((BukkitHuskSync) plugin).renderMapFromFile(event.getMap())); + getPlugin().runAsync(() -> ((BukkitHuskSync) plugin).renderPersistedMap(event.getMap())); } } diff --git a/bukkit/src/main/java/net/william278/husksync/util/BukkitMapPersister.java b/bukkit/src/main/java/net/william278/husksync/maps/BukkitMapHandler.java similarity index 67% rename from bukkit/src/main/java/net/william278/husksync/util/BukkitMapPersister.java rename to bukkit/src/main/java/net/william278/husksync/maps/BukkitMapHandler.java index d24756b1..353b4d19 100644 --- a/bukkit/src/main/java/net/william278/husksync/util/BukkitMapPersister.java +++ b/bukkit/src/main/java/net/william278/husksync/maps/BukkitMapHandler.java @@ -17,15 +17,14 @@ * limitations under the License. */ -package net.william278.husksync.util; +package net.william278.husksync.maps; import com.google.common.collect.Lists; import de.tr7zw.changeme.nbtapi.NBT; import de.tr7zw.changeme.nbtapi.iface.ReadWriteNBT; import de.tr7zw.changeme.nbtapi.iface.ReadableNBT; -import net.querz.nbt.io.NBTUtil; -import net.querz.nbt.tag.CompoundTag; import net.william278.husksync.BukkitHuskSync; +import net.william278.husksync.redis.RedisManager; import net.william278.mapdataapi.MapBanner; import net.william278.mapdataapi.MapData; import org.bukkit.Bukkit; @@ -35,27 +34,29 @@ import org.bukkit.block.Container; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.BlockStateMeta; +import org.bukkit.inventory.meta.BundleMeta; import org.bukkit.inventory.meta.MapMeta; import org.bukkit.map.*; import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.awt.*; -import java.io.File; +import java.io.IOException; import java.util.List; import java.util.*; import java.util.function.Function; import java.util.logging.Level; -public interface BukkitMapPersister { +public interface BukkitMapHandler { // The map used to store HuskSync data in ItemStack NBT String MAP_DATA_KEY = "husksync:persisted_locked_map"; - // The key used to store the serialized map data in NBT - String MAP_PIXEL_DATA_KEY = "canvas_data"; - // The key used to store the map of World UIDs to MapView IDs in NBT - String MAP_VIEW_ID_MAPPINGS_KEY = "id_mappings"; + // Name of server the map originates from + String MAP_ORIGIN_KEY = "origin"; + // Original map id + String MAP_ID_KEY = "id"; /** * Persist locked maps in an array of {@link ItemStack}s @@ -99,11 +100,108 @@ public interface BukkitMapPersister { } else if (item.getItemMeta() instanceof BlockStateMeta b && b.getBlockState() instanceof Container box) { forEachMap(box.getInventory().getContents(), function); b.setBlockState(box); + item.setItemMeta(b); + } else if (item.getItemMeta() instanceof BundleMeta bundle) { + bundle.setItems(List.of(forEachMap(bundle.getItems().toArray(ItemStack[]::new), function))); + item.setItemMeta(bundle); } } return items; } + @Blocking + private void writeMapData(@NotNull String serverName, int mapId, MapData data) { + final byte[] dataBytes = getPlugin().getDataAdapter().toBytes(new AdaptableMapData(data)); + getRedisManager().setMapData(serverName, mapId, dataBytes); + getPlugin().getDatabase().saveMapData(serverName, mapId, dataBytes); + } + + @Nullable + @Blocking + private Map.Entry readMapData(@NotNull String serverName, int mapId) { + final Map.Entry readData = fetchMapData(serverName, mapId); + if (readData == null) { + return null; + } + return deserializeMapData(readData); + } + + @Nullable + @Blocking + private Map.Entry fetchMapData(@NotNull String serverName, int mapId) { + return fetchMapData(serverName, mapId, true); + } + + @Nullable + @Blocking + private Map.Entry fetchMapData(@NotNull String serverName, int mapId, boolean doReverseLookup) { + // Read from Redis cache + final byte[] redisData = getRedisManager().getMapData(serverName, mapId); + if (redisData != null) { + return new AbstractMap.SimpleImmutableEntry<>(redisData, true); + } + + // Read from database and set to Redis + @Nullable Map.Entry databaseData = getPlugin().getDatabase().getMapData(serverName, mapId); + if (databaseData != null) { + getRedisManager().setMapData(serverName, mapId, databaseData.getKey()); + return databaseData; + } + + // Otherwise, lookup a reverse map binding + if (doReverseLookup) { + return fetchReversedMapData(serverName, mapId); + } + return null; + } + + @Nullable + private Map.Entry fetchReversedMapData(@NotNull String serverName, int mapId) { + // Lookup binding from Redis cache, then fetch data if found + Map.Entry binding = getRedisManager().getReversedMapBound(serverName, mapId); + if (binding != null) { + return fetchMapData(binding.getKey(), binding.getValue(), false); + } + + // Lookup binding from database, then set to Redis & fetch data if found + binding = getPlugin().getDatabase().getMapBinding(serverName, mapId); + if (binding != null) { + getRedisManager().bindMapIds(binding.getKey(), binding.getValue(), serverName, mapId); + return fetchMapData(binding.getKey(), binding.getValue(), false); + } + return null; + } + + @Nullable + private Map.Entry deserializeMapData(@NotNull Map.Entry data) { + try { + return new AbstractMap.SimpleImmutableEntry<>( + getPlugin().getDataAdapter().fromBytes(data.getKey(), AdaptableMapData.class) + .getData(getPlugin().getDataVersion(getPlugin().getMinecraftVersion())), + data.getValue() + ); + } catch (IOException e) { + getPlugin().log(Level.WARNING, "Failed to deserialize map data", e); + return null; + } + } + + // Get the bound map ID + private int getBoundMapId(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName) { + // Get the map ID from Redis, if set + final Optional redisId = getRedisManager().getBoundMapId(fromServerName, fromMapId, toServerName); + if (redisId.isPresent()) { + return redisId.get(); + } + + // Get from the database; if found, set to Redis + final int result = getPlugin().getDatabase().getBoundMapId(fromServerName, fromMapId, toServerName); + if (result != -1) { + getPlugin().getRedisManager().bindMapIds(fromServerName, fromMapId, toServerName, result); + } + return result; + } + @NotNull private ItemStack persistMapView(@NotNull ItemStack map, @NotNull Player delegateRenderer) { final MapMeta meta = Objects.requireNonNull((MapMeta) map.getItemMeta()); @@ -131,95 +229,82 @@ public interface BukkitMapPersister { // Persist map data final ReadWriteNBT mapData = nbt.getOrCreateCompound(MAP_DATA_KEY); - final String worldUid = view.getWorld().getUID().toString(); - mapData.setByteArray(MAP_PIXEL_DATA_KEY, canvas.extractMapData().toBytes()); - nbt.getOrCreateCompound(MAP_VIEW_ID_MAPPINGS_KEY).setInteger(worldUid, view.getId()); - getPlugin().debug(String.format("Saved data for locked map (#%s, UID: %s)", view.getId(), worldUid)); + final String serverName = getPlugin().getServerName(); + mapData.setString(MAP_ORIGIN_KEY, serverName); + mapData.setInteger(MAP_ID_KEY, meta.getMapId()); + if (readMapData(serverName, meta.getMapId()) == null) { + writeMapData(serverName, meta.getMapId(), canvas.extractMapData()); + } + getPlugin().debug(String.format("Saved data for locked map (#%s, server: %s)", view.getId(), serverName)); }); return map; } + @SuppressWarnings("deprecation") @NotNull private ItemStack applyMapView(@NotNull ItemStack map) { - final int dataVersion = getPlugin().getDataVersion(getPlugin().getMinecraftVersion()); final MapMeta meta = Objects.requireNonNull((MapMeta) map.getItemMeta()); NBT.get(map, nbt -> { if (!nbt.hasTag(MAP_DATA_KEY)) { return; } final ReadableNBT mapData = nbt.getCompound(MAP_DATA_KEY); - final ReadableNBT mapIds = nbt.getCompound(MAP_VIEW_ID_MAPPINGS_KEY); - if (mapData == null || mapIds == null) { + if (mapData == null) { return; } - // Search for an existing map view - Optional world = Optional.empty(); - for (String worldUid : mapIds.getKeys()) { - world = getPlugin().getServer().getWorlds().stream() - .map(w -> w.getUID().toString()).filter(u -> u.equals(worldUid)) - .findFirst(); - if (world.isPresent()) { - break; - } - } - if (world.isPresent()) { - final String uid = world.get(); - final Optional existingView = this.getMapView(mapIds.getInteger(uid)); - if (existingView.isPresent()) { - final MapView view = existingView.get(); - view.setLocked(true); - meta.setMapView(view); - map.setItemMeta(meta); - getPlugin().debug(String.format("View exists (#%s); updated map (UID: %s)", view.getId(), uid)); - return; - } + // Determine map ID + final String originServerName = mapData.getString(MAP_ORIGIN_KEY); + final String currentServerName = getPlugin().getServerName(); + final int originalMapId = mapData.getInteger(MAP_ID_KEY); + int newId = currentServerName.equals(originServerName) + ? originalMapId : getBoundMapId(originServerName, originalMapId, currentServerName); + if (newId != -1) { + meta.setMapId(newId); + map.setItemMeta(meta); + getPlugin().debug(String.format("Map ID set to %s", newId)); + return; } // Read the pixel data and generate a map view otherwise - final MapData canvasData; - try { - getPlugin().debug("Deserializing map data from NBT and generating view..."); - canvasData = MapData.fromByteArray( - dataVersion, - Objects.requireNonNull(mapData.getByteArray(MAP_PIXEL_DATA_KEY), "Pixel data null!")); - } catch (Throwable e) { - getPlugin().log(Level.WARNING, "Failed to deserialize map data from NBT", e); - return; - } + getPlugin().debug("Deserializing map data from NBT and generating view..."); + final MapData canvasData = Objects.requireNonNull(readMapData(originServerName, originalMapId), "Pixel data null!").getKey(); // Add a renderer to the map with the data and save to file final MapView view = generateRenderedMap(canvasData); - final String worldUid = getDefaultMapWorld().getUID().toString(); meta.setMapView(view); map.setItemMeta(meta); - saveMapToFile(canvasData, view.getId()); - // Set the map view ID in NBT - NBT.modify(map, editable -> { - Objects.requireNonNull(editable.getCompound(MAP_VIEW_ID_MAPPINGS_KEY), - "Map view ID mappings compound is null") - .setInteger(worldUid, view.getId()); - }); - getPlugin().debug(String.format("Generated view (#%s) and updated map (UID: %s)", view.getId(), worldUid)); + // Bind in the database & Redis + final int id = view.getId(); + getRedisManager().bindMapIds(originServerName, originalMapId, currentServerName, id); + getPlugin().getDatabase().setMapBinding(originServerName, originalMapId, currentServerName, id); + + getPlugin().debug(String.format("Bound map to view (#%s) on server %s", id, currentServerName)); }); return map; } - default void renderMapFromFile(@NotNull MapView view) { - final File mapFile = new File(getMapCacheFolder(), view.getId() + ".dat"); - if (!mapFile.exists()) { + default void renderPersistedMap(@NotNull MapView view) { + if (getMapView(view.getId()).isPresent()) { return; } - final MapData canvasData; - try { - canvasData = MapData.fromNbt(mapFile); - } catch (Throwable e) { - getPlugin().log(Level.WARNING, "Failed to deserialize map data from file", e); + @Nullable final Map.Entry data = readMapData(getPlugin().getServerName(), view.getId()); + if (data == null) { + final World world = view.getWorld() == null ? getDefaultMapWorld() : view.getWorld(); + getPlugin().debug("Not rendering map: no data in DB for world %s, map #%s." + .formatted(world.getName(), view.getId())); return; } + if (data.getValue()) { + // from this server, doesn't need tweaking + return; + } + + final MapData canvasData = data.getKey(); + // Create a new map view renderer with the map data color at each pixel // use view.removeRenderer() to remove all this maps renderers view.getRenderers().forEach(view::removeRenderer); @@ -233,32 +318,6 @@ public interface BukkitMapPersister { setMapView(view); } - default void saveMapToFile(@NotNull MapData data, int id) { - getPlugin().runAsync(() -> { - final File mapFile = new File(getMapCacheFolder(), id + ".dat"); - if (mapFile.exists()) { - return; - } - - try { - final CompoundTag rootTag = new CompoundTag(); - rootTag.put("data", data.toNBT().getTag()); - NBTUtil.write(rootTag, mapFile); - } catch (Throwable e) { - getPlugin().log(Level.WARNING, "Failed to serialize map data to file", e); - } - }); - } - - @NotNull - private File getMapCacheFolder() { - final File mapCache = new File(getPlugin().getDataFolder(), "maps"); - if (!mapCache.exists() && !mapCache.mkdirs()) { - getPlugin().log(Level.WARNING, "Failed to create maps folder"); - } - return mapCache; - } - // Sets the renderer of a map, and returns the generated MapView @NotNull private MapView generateRenderedMap(@NotNull MapData canvasData) { @@ -362,6 +421,8 @@ public interface BukkitMapPersister { @SuppressWarnings({"deprecation", "removal"}) class PersistentMapCanvas implements MapCanvas { + private static final String BANNER_PREFIX = "banner_"; + private final int mapDataVersion; private final MapView mapView; private final int[][] pixels = new int[128][128]; @@ -451,7 +512,6 @@ public interface BukkitMapPersister { @NotNull private MapData extractMapData() { final List banners = Lists.newArrayList(); - final String BANNER_PREFIX = "banner_"; for (int i = 0; i < getCursors().size(); i++) { final MapCursor cursor = getCursors().getCursor(i); //#if MC==12001 @@ -477,6 +537,9 @@ public interface BukkitMapPersister { @NotNull Map getMapViews(); + @ApiStatus.Internal + RedisManager getRedisManager(); + @ApiStatus.Internal @NotNull BukkitHuskSync getPlugin(); diff --git a/bukkit/src/main/java/net/william278/husksync/migrator/LegacyMigrator.java b/bukkit/src/main/java/net/william278/husksync/migrator/LegacyMigrator.java index 3b9eff40..707879b2 100644 --- a/bukkit/src/main/java/net/william278/husksync/migrator/LegacyMigrator.java +++ b/bukkit/src/main/java/net/william278/husksync/migrator/LegacyMigrator.java @@ -146,7 +146,7 @@ public class LegacyMigrator extends Migrator { try { plugin.getDatabase().addSnapshot(data.user(), convertedData); } catch (Throwable e) { - plugin.log(Level.SEVERE, "Failed to migrate legacy data for " + data.user().getUsername() + ": " + e.getMessage()); + plugin.log(Level.SEVERE, "Failed to migrate legacy data for " + data.user().getName() + ": " + e.getMessage()); return; } diff --git a/common/build.gradle b/common/build.gradle index c5b944cc..011cb8b8 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -6,6 +6,7 @@ dependencies { api 'commons-io:commons-io:2.18.0' api 'org.apache.commons:commons-text:1.13.0' api 'net.william278:minedown:1.8.2' + api 'net.william278:mapdataapi:2.0' api 'org.json:json:20250107' api 'com.google.code.gson:gson:2.12.1' api 'com.fatboyindustrial.gson-javatime-serialisers:gson-javatime-serialisers:1.1.2' diff --git a/common/src/main/java/net/william278/husksync/command/EnderChestCommand.java b/common/src/main/java/net/william278/husksync/command/EnderChestCommand.java index 81395e9b..b9cd1139 100644 --- a/common/src/main/java/net/william278/husksync/command/EnderChestCommand.java +++ b/common/src/main/java/net/william278/husksync/command/EnderChestCommand.java @@ -51,7 +51,7 @@ public class EnderChestCommand extends ItemsCommand { } // Display opening message - plugin.getLocales().getLocale("ender_chest_viewer_opened", user.getUsername(), + plugin.getLocales().getLocale("ender_chest_viewer_opened", user.getName(), snapshot.getTimestamp().format(DateTimeFormatter .ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT))) .ifPresent(viewer::sendMessage); @@ -60,8 +60,8 @@ public class EnderChestCommand extends ItemsCommand { final Data.Items.EnderChest enderChest = optionalEnderChest.get(); viewer.showGui( enderChest, - plugin.getLocales().getLocale("ender_chest_viewer_menu_title", user.getUsername()) - .orElse(new MineDown(String.format("%s's Ender Chest", user.getUsername()))), + plugin.getLocales().getLocale("ender_chest_viewer_menu_title", user.getName()) + .orElse(new MineDown(String.format("%s's Ender Chest", user.getName()))), allowEdit, enderChest.getSlotCount(), (itemsOnClose) -> { diff --git a/common/src/main/java/net/william278/husksync/command/HuskSyncCommand.java b/common/src/main/java/net/william278/husksync/command/HuskSyncCommand.java index ae8c41e4..355cbd41 100644 --- a/common/src/main/java/net/william278/husksync/command/HuskSyncCommand.java +++ b/common/src/main/java/net/william278/husksync/command/HuskSyncCommand.java @@ -69,7 +69,8 @@ public class HuskSyncCommand extends PluginCommand { AboutMenu.Credit.of("HookWoods").description("Code"), AboutMenu.Credit.of("Preva1l").description("Code"), AboutMenu.Credit.of("hanbings").description("Code (Fabric porting)"), - AboutMenu.Credit.of("Stampede2011").description("Code (Fabric mixins)")) + AboutMenu.Credit.of("Stampede2011").description("Code (Fabric mixins)"), + AboutMenu.Credit.of("VinerDream").description("Code")) .credits("Translators", AboutMenu.Credit.of("Namiu").description("Japanese (ja-jp)"), AboutMenu.Credit.of("anchelthe").description("Spanish (es-es)"), diff --git a/common/src/main/java/net/william278/husksync/command/InventoryCommand.java b/common/src/main/java/net/william278/husksync/command/InventoryCommand.java index 0eadbb0a..95f85b02 100644 --- a/common/src/main/java/net/william278/husksync/command/InventoryCommand.java +++ b/common/src/main/java/net/william278/husksync/command/InventoryCommand.java @@ -52,7 +52,7 @@ public class InventoryCommand extends ItemsCommand { } // Display opening message - plugin.getLocales().getLocale("inventory_viewer_opened", user.getUsername(), + plugin.getLocales().getLocale("inventory_viewer_opened", user.getName(), snapshot.getTimestamp().format(DateTimeFormatter .ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT))) .ifPresent(viewer::sendMessage); @@ -61,8 +61,8 @@ public class InventoryCommand extends ItemsCommand { final Data.Items.Inventory inventory = optionalInventory.get(); viewer.showGui( inventory, - plugin.getLocales().getLocale("inventory_viewer_menu_title", user.getUsername()) - .orElse(new MineDown(String.format("%s's Inventory", user.getUsername()))), + plugin.getLocales().getLocale("inventory_viewer_menu_title", user.getName()) + .orElse(new MineDown(String.format("%s's Inventory", user.getName()))), allowEdit, inventory.getSlotCount(), (itemsOnClose) -> { diff --git a/common/src/main/java/net/william278/husksync/command/PluginCommand.java b/common/src/main/java/net/william278/husksync/command/PluginCommand.java index b1eda665..bc6c8c87 100644 --- a/common/src/main/java/net/william278/husksync/command/PluginCommand.java +++ b/common/src/main/java/net/william278/husksync/command/PluginCommand.java @@ -83,7 +83,7 @@ public abstract class PluginCommand extends Command { () -> CommandSyntaxException.BUILT_IN_EXCEPTIONS.dispatcherUnknownArgument().createWithContext(reader) ); }, (context, builder) -> { - plugin.getOnlineUsers().forEach(u -> builder.suggest(u.getUsername())); + plugin.getOnlineUsers().forEach(u -> builder.suggest(u.getName())); return builder.buildFuture(); }); } diff --git a/common/src/main/java/net/william278/husksync/command/UserDataCommand.java b/common/src/main/java/net/william278/husksync/command/UserDataCommand.java index dd1a7eef..b38fab36 100644 --- a/common/src/main/java/net/william278/husksync/command/UserDataCommand.java +++ b/common/src/main/java/net/william278/husksync/command/UserDataCommand.java @@ -113,7 +113,7 @@ public class UserDataCommand extends PluginCommand { plugin.getLocales().getLocale("data_deleted", version.toString().split("-")[0], version.toString(), - user.getUsername(), + user.getName(), user.getUuid().toString()) .ifPresent(executor::sendMessage); } @@ -147,7 +147,7 @@ public class UserDataCommand extends PluginCommand { plugin.getDataSyncer().saveData(user, data, (u, s) -> { redis.getUserData(u).ifPresent(d -> redis.setUserData(u, s, RedisKeyType.TTL_1_YEAR)); redis.sendUserDataUpdate(u, s); - plugin.getLocales().getLocale("data_restored", u.getUsername(), u.getUuid().toString(), + plugin.getLocales().getLocale("data_restored", u.getName(), u.getUuid().toString(), s.getShortId(), s.getId().toString()).ifPresent(executor::sendMessage); }); } @@ -169,7 +169,7 @@ public class UserDataCommand extends PluginCommand { plugin.getDatabase().pinSnapshot(user, data.getId()); } plugin.getLocales().getLocale(data.isPinned() ? "data_unpinned" : "data_pinned", data.getShortId(), - data.getId().toString(), user.getUsername(), user.getUuid().toString()) + data.getId().toString(), user.getName(), user.getUuid().toString()) .ifPresent(executor::sendMessage); } @@ -187,7 +187,7 @@ public class UserDataCommand extends PluginCommand { final DataSnapshot.Packed userData = data.get(); final UserDataDumper dumper = UserDataDumper.create(userData, user, plugin); try { - plugin.getLocales().getLocale("data_dumped", userData.getShortId(), user.getUsername(), + plugin.getLocales().getLocale("data_dumped", userData.getShortId(), user.getName(), (type == DumpType.WEB ? dumper.toWeb() : dumper.toFile())) .ifPresent(executor::sendMessage); } catch (Throwable e) { diff --git a/common/src/main/java/net/william278/husksync/database/Database.java b/common/src/main/java/net/william278/husksync/database/Database.java index c03bc2ad..628d5b6c 100644 --- a/common/src/main/java/net/william278/husksync/database/Database.java +++ b/common/src/main/java/net/william278/husksync/database/Database.java @@ -26,6 +26,7 @@ import net.william278.husksync.data.DataSnapshot; import net.william278.husksync.user.User; import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -56,8 +57,8 @@ public abstract class Database { @SuppressWarnings("SameParameterValue") @NotNull protected final String[] getSchemaStatements(@NotNull String schemaFileName) throws IOException { - return formatStatementTables(new String(Objects.requireNonNull(plugin.getResource(schemaFileName)) - .readAllBytes(), StandardCharsets.UTF_8)).split(";"); + return Arrays.stream(formatStatementTables(new String(Objects.requireNonNull(plugin.getResource(schemaFileName)) + .readAllBytes(), StandardCharsets.UTF_8)).split(";")).filter(s -> !s.isBlank()).toArray(String[]::new); } /** @@ -70,7 +71,9 @@ public abstract class Database { protected final String formatStatementTables(@NotNull String sql) { final Settings.DatabaseSettings settings = plugin.getSettings().getDatabase(); return sql.replaceAll("%users_table%", settings.getTableName(TableName.USERS)) - .replaceAll("%user_data_table%", settings.getTableName(TableName.USER_DATA)); + .replaceAll("%user_data_table%", settings.getTableName(TableName.USER_DATA)) + .replaceAll("%map_data_table%", settings.getTableName(TableName.MAP_DATA)) + .replaceAll("%map_ids_table%", settings.getTableName(TableName.MAP_IDS)); } /** @@ -246,6 +249,58 @@ public abstract class Database { }); } + /** + * Write map data to a database + * + * @param serverName Name of the server the map originates from + * @param mapId Original map ID + * @param data Map data + */ + @Blocking + public abstract void saveMapData(@NotNull String serverName, int mapId, byte @NotNull [] data); + + /** + * Read map data from a database + * + * @param serverName Name of the server the map originates from + * @param mapId Original map ID + * @return Map.Entry (key: map data, value: is from current world) + */ + @Blocking + public abstract @Nullable Map.Entry getMapData(@NotNull String serverName, int mapId); + + /** + * Get a map server -> ID binding in the database + * + * @param serverName Name of the server the map originates from + * @param mapId Original map ID + * @return Map.Entry (key: server name, value: map ID) + */ + @Blocking + public abstract @Nullable Map.Entry getMapBinding(@NotNull String serverName, int mapId); + + /** + * Bind map IDs across different servers + * + * @param fromServerName Name of the server the map originates from + * @param fromMapId Original map ID + * @param toServerName Name of the new server + * @param toMapId New map ID + */ + @Blocking + public abstract void setMapBinding(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName, int toMapId); + + /** + * Get map ID for the new server + * + * @param fromServerName Name of the server the map originates from + * @param fromMapId Original map ID + * @param toServerName Name of the new server + * @return New map ID or -1 if not found + */ + @Blocking + public abstract int getBoundMapId(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName); + /** * Wipes all {@link User} entries from the database. * This should only be used when preparing tables for a data migration. @@ -283,7 +338,9 @@ public abstract class Database { @Getter public enum TableName { USERS("husksync_users"), - USER_DATA("husksync_user_data"); + USER_DATA("husksync_user_data"), + MAP_DATA("husksync_map_data"), + MAP_IDS("husksync_map_ids"); private final String defaultName; diff --git a/common/src/main/java/net/william278/husksync/database/MongoDbDatabase.java b/common/src/main/java/net/william278/husksync/database/MongoDbDatabase.java index 0f4c248b..68daaf63 100644 --- a/common/src/main/java/net/william278/husksync/database/MongoDbDatabase.java +++ b/common/src/main/java/net/william278/husksync/database/MongoDbDatabase.java @@ -35,13 +35,11 @@ import org.bson.conversions.Bson; import org.bson.types.Binary; import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.time.Instant; import java.time.OffsetDateTime; -import java.util.List; -import java.util.Optional; -import java.util.TimeZone; -import java.util.UUID; +import java.util.*; import java.util.logging.Level; public class MongoDbDatabase extends Database { @@ -50,11 +48,15 @@ public class MongoDbDatabase extends Database { private final String usersTable; private final String userDataTable; + private final String mapDataTable; + private final String mapIdsTable; public MongoDbDatabase(@NotNull HuskSync plugin) { super(plugin); this.usersTable = plugin.getSettings().getDatabase().getTableName(TableName.USERS); this.userDataTable = plugin.getSettings().getDatabase().getTableName(TableName.USER_DATA); + this.mapDataTable = plugin.getSettings().getDatabase().getTableName(TableName.MAP_DATA); + this.mapIdsTable = plugin.getSettings().getDatabase().getTableName(TableName.MAP_IDS); } @Override @@ -74,9 +76,15 @@ public class MongoDbDatabase extends Database { if (mongoCollectionHelper.getCollection(userDataTable) == null) { mongoCollectionHelper.createCollection(userDataTable); } + if (mongoCollectionHelper.getCollection(mapDataTable) == null) { + mongoCollectionHelper.createCollection(mapDataTable); + } + if (mongoCollectionHelper.getCollection(mapIdsTable) == null) { + mongoCollectionHelper.createCollection(mapIdsTable); + } } catch (Exception e) { throw new IllegalStateException("Failed to establish a connection to the MongoDB database. " + - "Please check the supplied database credentials in the config file", e); + "Please check the supplied database credentials in the config file", e); } } @@ -99,7 +107,7 @@ public class MongoDbDatabase extends Database { try { getUser(user.getUuid()).ifPresentOrElse( existingUser -> { - if (!existingUser.getUsername().equals(user.getUsername())) { + if (!existingUser.getName().equals(user.getName())) { // Update a user's name if it has changed in the database try { Document filter = new Document("uuid", existingUser.getUuid()); @@ -108,7 +116,7 @@ public class MongoDbDatabase extends Database { throw new MongoException("User document returned null!"); } - Bson updates = Updates.set("username", user.getUsername()); + Bson updates = Updates.set("username", user.getName()); mongoCollectionHelper.updateDocument(usersTable, doc, updates); } catch (MongoException e) { plugin.log(Level.SEVERE, "Failed to insert a user into the database", e); @@ -118,7 +126,7 @@ public class MongoDbDatabase extends Database { () -> { // Insert new player data into the database try { - Document doc = new Document("uuid", user.getUuid()).append("username", user.getUsername()); + Document doc = new Document("uuid", user.getUuid()).append("username", user.getName()); mongoCollectionHelper.insertDocument(usersTable, doc); } catch (MongoException e) { plugin.log(Level.SEVERE, "Failed to insert a user into the database", e); @@ -345,6 +353,85 @@ public class MongoDbDatabase extends Database { } } + @Blocking + @Override + public void saveMapData(@NotNull String serverName, int mapId, byte @NotNull [] data) { + try { + Document doc = new Document("server_name", serverName) + .append("map_id", mapId) + .append("data", new Binary(data)); + mongoCollectionHelper.insertDocument(mapDataTable, doc); + } catch (MongoException e) { + plugin.log(Level.SEVERE, "Failed to write map data to the database", e); + } + } + + @Blocking + @Override + public @Nullable Map.Entry getMapData(@NotNull String serverName, int mapId) { + try { + Document filter = new Document("server_name", serverName).append("map_id", mapId); + FindIterable iterable = mongoCollectionHelper.getCollection(mapDataTable).find(filter); + Document doc = iterable.first(); + if (doc != null) { + final Binary bin = doc.get("data", Binary.class); + return Map.entry(bin.getData(), true); + } + } catch (MongoException e) { + plugin.log(Level.SEVERE, "Failed to get map data from the database", e); + } + return null; + } + + @Blocking + @Override + public @Nullable Map.Entry getMapBinding(@NotNull String serverName, int mapId) { + final Document filter = new Document("to_server_name", serverName).append("to_id", mapId); + final FindIterable iterable = mongoCollectionHelper.getCollection(mapIdsTable).find(filter); + final Document doc = iterable.first(); + if (doc != null) { + return new AbstractMap.SimpleImmutableEntry<>( + doc.getString("server_name"), + doc.getInteger("to_id") + ); + } + return null; + } + + @Blocking + @Override + public void setMapBinding(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName, int toMapId) { + try { + final Document doc = new Document("from_server_name", fromServerName) + .append("from_id", fromMapId) + .append("to_server_name", toServerName) + .append("to_id", toMapId); + mongoCollectionHelper.insertDocument(mapIdsTable, doc); + } catch (MongoException e) { + plugin.log(Level.SEVERE, "Failed to connect map IDs in the database", e); + } + } + + @Blocking + @Override + public int getBoundMapId(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName) { + try { + final Document filter = new Document("from_server_name", fromServerName) + .append("from_id", fromMapId) + .append("to_server_name", toServerName); + final FindIterable iterable = mongoCollectionHelper.getCollection(mapIdsTable).find(filter); + + final Document doc = iterable.first(); + if (doc != null) { + return doc.getInteger("to_id"); + } + return -1; + } catch (MongoException e) { + plugin.log(Level.SEVERE, "Failed to get new map id from the database", e); + return -1; + } + } + @Blocking @Override public void wipeDatabase() { diff --git a/common/src/main/java/net/william278/husksync/database/MySqlDatabase.java b/common/src/main/java/net/william278/husksync/database/MySqlDatabase.java index 3637b0b0..8779c102 100644 --- a/common/src/main/java/net/william278/husksync/database/MySqlDatabase.java +++ b/common/src/main/java/net/william278/husksync/database/MySqlDatabase.java @@ -27,6 +27,7 @@ import net.william278.husksync.data.DataSnapshot; import net.william278.husksync.user.User; import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -127,11 +128,11 @@ public class MySqlDatabase extends Database { } } catch (SQLException e) { throw new IllegalStateException("Failed to create database tables. Please ensure you are running MySQL v8.0+ " + - "and that your connecting user account has privileges to create tables.", e); + "and that your connecting user account has privileges to create tables.", e); } } catch (SQLException | IOException e) { throw new IllegalStateException("Failed to establish a connection to the MySQL database. " + - "Please check the supplied database credentials in the config file", e); + "Please check the supplied database credentials in the config file", e); } } @@ -140,7 +141,7 @@ public class MySqlDatabase extends Database { public void ensureUser(@NotNull User user) { getUser(user.getUuid()).ifPresentOrElse( existingUser -> { - if (!existingUser.getUsername().equals(user.getUsername())) { + if (!existingUser.getName().equals(user.getName())) { // Update a user's name if it has changed in the database try (Connection connection = getConnection()) { try (PreparedStatement statement = connection.prepareStatement(formatStatementTables(""" @@ -148,11 +149,12 @@ public class MySqlDatabase extends Database { SET `username`=? WHERE `uuid`=?"""))) { - statement.setString(1, user.getUsername()); + statement.setString(1, user.getName()); statement.setString(2, existingUser.getUuid().toString()); statement.executeUpdate(); } - plugin.log(Level.INFO, "Updated " + user.getUsername() + "'s name in the database (" + existingUser.getUsername() + " -> " + user.getUsername() + ")"); + plugin.log(Level.INFO, "Updated " + user.getName() + "'s name in the database (" + + existingUser.getName() + " -> " + user.getName() + ")"); } catch (SQLException e) { plugin.log(Level.SEVERE, "Failed to update a user's name on the database", e); } @@ -166,7 +168,7 @@ public class MySqlDatabase extends Database { VALUES (?,?);"""))) { statement.setString(1, user.getUuid().toString()); - statement.setString(2, user.getUsername()); + statement.setString(2, user.getName()); statement.executeUpdate(); } } catch (SQLException e) { @@ -433,6 +435,120 @@ public class MySqlDatabase extends Database { } } + @Blocking + @Override + public void saveMapData(@NotNull String serverName, int mapId, byte @NotNull [] data) { + try (Connection connection = getConnection()) { + try (PreparedStatement statement = connection.prepareStatement(formatStatementTables(""" + INSERT INTO `%map_data_table%` + (`server_name`,`map_id`,`data`) + VALUES (?,?,?);"""))) { + statement.setString(1, serverName); + statement.setInt(2, mapId); + statement.setBlob(3, new ByteArrayInputStream(data)); + statement.executeUpdate(); + } + } catch (SQLException | DataAdapter.AdaptionException e) { + plugin.log(Level.SEVERE, "Failed to write map data to the database", e); + } + } + + @Blocking + @Override + public @Nullable Map.Entry getMapData(@NotNull String serverName, int mapId) { + try (Connection connection = getConnection()) { + try (PreparedStatement statement = connection.prepareStatement(formatStatementTables(""" + SELECT `data` + FROM `%map_data_table%` + WHERE `server_name`=? AND `map_id`=? + LIMIT 1;"""))) { + statement.setString(1, serverName); + statement.setInt(2, mapId); + + final ResultSet resultSet = statement.executeQuery(); + if (resultSet.next()) { + final Blob blob = resultSet.getBlob("data"); + final byte[] dataByteArray = blob.getBytes(1, (int) blob.length()); + blob.free(); + return Map.entry(dataByteArray, true); + } + } + } catch (SQLException | DataAdapter.AdaptionException e) { + plugin.log(Level.SEVERE, "Failed to get map data from the database", e); + } + return null; + } + + @Blocking + @Override + public @Nullable Map.Entry getMapBinding(@NotNull String serverName, int mapId) { + try (Connection connection = getConnection()) { + try (PreparedStatement statement = connection.prepareStatement(formatStatementTables(""" + SELECT `from_server_name`, `from_id` + FROM `%map_ids_table%` + WHERE `to_server_name`=? AND `to_id`=? + LIMIT 1; + """))) { + statement.setString(1, serverName); + statement.setInt(2, mapId); + + final ResultSet resultSet = statement.executeQuery(); + if (resultSet.next()) { + return new AbstractMap.SimpleImmutableEntry<>( + resultSet.getString("from_server_name"), + resultSet.getInt("from_id") + ); + } + } + } catch (SQLException | DataAdapter.AdaptionException e) { + plugin.log(Level.SEVERE, "Failed to get map data from the database", e); + } + return null; + } + + @Blocking + @Override + public void setMapBinding(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName, int toMapId) { + try (Connection connection = getConnection()) { + try (PreparedStatement statement = connection.prepareStatement(formatStatementTables(""" + INSERT INTO `%map_ids_table%` + (`from_server_name`,`from_id`,`to_server_name`,`to_id`) + VALUES (?,?,?,?);"""))) { + statement.setString(1, fromServerName); + statement.setInt(2, fromMapId); + statement.setString(3, toServerName); + statement.setInt(4, toMapId); + statement.executeUpdate(); + } + } catch (SQLException | DataAdapter.AdaptionException e) { + plugin.log(Level.SEVERE, "Failed to connect map IDs in the database", e); + } + } + + @Blocking + @Override + public int getBoundMapId(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName) { + try (Connection connection = getConnection()) { + try (PreparedStatement statement = connection.prepareStatement(formatStatementTables(""" + SELECT `to_id` + FROM `%map_ids_table%` + WHERE `from_server_name`=? AND `from_id`=? AND `to_server_name`=? + LIMIT 1;"""))) { + statement.setString(1, fromServerName); + statement.setInt(2, fromMapId); + statement.setString(3, toServerName); + + final ResultSet resultSet = statement.executeQuery(); + if (resultSet.next()) { + return resultSet.getInt("to_id"); + } + } + } catch (SQLException | DataAdapter.AdaptionException e) { + plugin.log(Level.SEVERE, "Failed to get new map id from the database", e); + } + return -1; + } + @Override public void wipeDatabase() { try (Connection connection = getConnection()) { diff --git a/common/src/main/java/net/william278/husksync/database/PostgresDatabase.java b/common/src/main/java/net/william278/husksync/database/PostgresDatabase.java index d00a202f..a776ab17 100644 --- a/common/src/main/java/net/william278/husksync/database/PostgresDatabase.java +++ b/common/src/main/java/net/william278/husksync/database/PostgresDatabase.java @@ -27,6 +27,7 @@ import net.william278.husksync.data.DataSnapshot; import net.william278.husksync.user.User; import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.sql.*; @@ -120,11 +121,11 @@ public class PostgresDatabase extends Database { } } catch (SQLException e) { throw new IllegalStateException("Failed to create database tables. Please ensure you are running PostgreSQL " + - "and that your connecting user account has privileges to create tables.", e); + "and that your connecting user account has privileges to create tables.", e); } } catch (SQLException | IOException e) { throw new IllegalStateException("Failed to establish a connection to the PostgreSQL database. " + - "Please check the supplied database credentials in the config file", e); + "Please check the supplied database credentials in the config file", e); } } @@ -133,7 +134,7 @@ public class PostgresDatabase extends Database { public void ensureUser(@NotNull User user) { getUser(user.getUuid()).ifPresentOrElse( existingUser -> { - if (!existingUser.getUsername().equals(user.getUsername())) { + if (!existingUser.getName().equals(user.getName())) { // Update a user's name if it has changed in the database try (Connection connection = getConnection()) { try (PreparedStatement statement = connection.prepareStatement(formatStatementTables(""" @@ -141,11 +142,11 @@ public class PostgresDatabase extends Database { SET username=? WHERE uuid=?;"""))) { - statement.setString(1, user.getUsername()); + statement.setString(1, user.getName()); statement.setObject(2, existingUser.getUuid()); statement.executeUpdate(); } - plugin.log(Level.INFO, "Updated " + user.getUsername() + "'s name in the database (" + existingUser.getUsername() + " -> " + user.getUsername() + ")"); + plugin.log(Level.INFO, "Updated " + user.getName() + "'s name in the database (" + existingUser.getName() + " -> " + user.getName() + ")"); } catch (SQLException e) { plugin.log(Level.SEVERE, "Failed to update a user's name on the database", e); } @@ -159,7 +160,7 @@ public class PostgresDatabase extends Database { VALUES (?,?);"""))) { statement.setObject(1, user.getUuid()); - statement.setString(2, user.getUsername()); + statement.setString(2, user.getName()); statement.executeUpdate(); } } catch (SQLException e) { @@ -430,6 +431,118 @@ public class PostgresDatabase extends Database { } } + @Blocking + @Override + public void saveMapData(@NotNull String serverName, int mapId, byte @NotNull [] data) { + try (Connection connection = getConnection()) { + try (PreparedStatement statement = connection.prepareStatement(formatStatementTables(""" + INSERT INTO %map_data_table% + (server_name,map_id,data) + VALUES (?,?,?);"""))) { + statement.setString(1, serverName); + statement.setInt(2, mapId); + statement.setBytes(3, data); + statement.executeUpdate(); + } + } catch (SQLException | DataAdapter.AdaptionException e) { + plugin.log(Level.SEVERE, "Failed to write map data to the database", e); + } + } + + @Blocking + @Override + public @Nullable Map.Entry getMapData(@NotNull String serverName, int mapId) { + try (Connection connection = getConnection()) { + try (PreparedStatement statement = connection.prepareStatement(formatStatementTables(""" + SELECT data + FROM %map_data_table% + WHERE server_name=? AND map_id=? + LIMIT 1;"""))) { + statement.setString(1, serverName); + statement.setInt(2, mapId); + final ResultSet resultSet = statement.executeQuery(); + if (resultSet.next()) { + final byte[] data = resultSet.getBytes("data"); + return new AbstractMap.SimpleImmutableEntry<>(data, true); + } + return null; + } + } catch (SQLException | DataAdapter.AdaptionException e) { + plugin.log(Level.SEVERE, "Failed to get map data from the database", e); + } + return null; + } + + @Blocking + @Override + public @Nullable Map.Entry getMapBinding(@NotNull String serverName, int mapId) { + try (Connection connection = getConnection()) { + try (PreparedStatement statement = connection.prepareStatement(formatStatementTables(""" + SELECT from_server_name, from_id + FROM %map_ids_table% + WHERE to_server_name=? AND to_id=? + LIMIT 1; + """))) { + statement.setString(1, serverName); + statement.setInt(2, mapId); + + final ResultSet resultSet = statement.executeQuery(); + if (resultSet.next()) { + return new AbstractMap.SimpleImmutableEntry<>( + resultSet.getString("from_server_name"), + resultSet.getInt("from_id") + ); + } + } + } catch (SQLException | DataAdapter.AdaptionException e) { + plugin.log(Level.SEVERE, "Failed to get map data from the database", e); + } + return null; + } + + @Blocking + @Override + public void setMapBinding(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName, int toMapId) { + try (Connection connection = getConnection()) { + try (PreparedStatement statement = connection.prepareStatement(formatStatementTables(""" + INSERT INTO %map_ids_table% + (from_server_name,from_id,to_server_name,to_id) + VALUES (?,?,?,?);"""))) { + statement.setString(1, fromServerName); + statement.setInt(2, fromMapId); + statement.setString(3, toServerName); + statement.setInt(4, toMapId); + statement.executeUpdate(); + } + } catch (SQLException | DataAdapter.AdaptionException e) { + plugin.log(Level.SEVERE, "Failed to connect map IDs in the database", e); + } + } + + @Blocking + @Override + public int getBoundMapId(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName) { + try (Connection connection = getConnection()) { + try (PreparedStatement statement = connection.prepareStatement(formatStatementTables(""" + SELECT to_id + FROM %map_ids_table% + WHERE from_server_name=? AND from_id=? AND to_server_name=? + LIMIT 1;"""))) { + statement.setString(1, fromServerName); + statement.setInt(2, fromMapId); + statement.setString(3, toServerName); + + final ResultSet resultSet = statement.executeQuery(); + if (resultSet.next()) { + return resultSet.getInt("to_id"); + } + } + } catch (SQLException | DataAdapter.AdaptionException e) { + plugin.log(Level.SEVERE, "Failed to get new map id from the database", e); + } + return -1; + } + @Override public void wipeDatabase() { try (Connection connection = getConnection()) { diff --git a/common/src/main/java/net/william278/husksync/maps/AdaptableMapData.java b/common/src/main/java/net/william278/husksync/maps/AdaptableMapData.java new file mode 100644 index 00000000..813d5e81 --- /dev/null +++ b/common/src/main/java/net/william278/husksync/maps/AdaptableMapData.java @@ -0,0 +1,45 @@ +/* + * This file is part of HuskSync, licensed under the Apache License 2.0. + * + * Copyright (c) William278 + * Copyright (c) contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.william278.husksync.maps; + +import com.google.gson.annotations.SerializedName; +import lombok.AllArgsConstructor; +import org.jetbrains.annotations.NotNull; +import net.william278.husksync.adapter.Adaptable; +import net.william278.mapdataapi.MapData; + +import java.io.IOException; + +@AllArgsConstructor +public class AdaptableMapData implements Adaptable { + + @SerializedName("data") + private final byte[] data; + + public AdaptableMapData(@NotNull MapData data) { + this(data.toBytes()); + } + + @NotNull + public MapData getData(int dataVersion) throws IOException { + return MapData.fromByteArray(dataVersion, data); + } + +} diff --git a/common/src/main/java/net/william278/husksync/redis/RedisKeyType.java b/common/src/main/java/net/william278/husksync/redis/RedisKeyType.java index fa1a1c36..26a973f7 100644 --- a/common/src/main/java/net/william278/husksync/redis/RedisKeyType.java +++ b/common/src/main/java/net/william278/husksync/redis/RedisKeyType.java @@ -27,7 +27,10 @@ public enum RedisKeyType { LATEST_SNAPSHOT, SERVER_SWITCH, - DATA_CHECKOUT; + DATA_CHECKOUT, + MAP_ID, + MAP_ID_REVERSED, + MAP_DATA; public static final int TTL_1_YEAR = 60 * 60 * 24 * 7 * 52; // 1 year public static final int TTL_10_SECONDS = 10; // 10 seconds diff --git a/common/src/main/java/net/william278/husksync/redis/RedisManager.java b/common/src/main/java/net/william278/husksync/redis/RedisManager.java index d778db1d..73386774 100644 --- a/common/src/main/java/net/william278/husksync/redis/RedisManager.java +++ b/common/src/main/java/net/william278/husksync/redis/RedisManager.java @@ -25,6 +25,7 @@ import net.william278.husksync.data.DataSnapshot; import net.william278.husksync.user.User; import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import redis.clients.jedis.*; import redis.clients.jedis.exceptions.JedisException; import redis.clients.jedis.util.Pool; @@ -261,7 +262,7 @@ public class RedisManager extends JedisPubSub { timeToLive, data.asBytes(plugin) ); - plugin.debug(String.format("[%s] Set %s key on Redis", user.getUsername(), RedisKeyType.LATEST_SNAPSHOT)); + plugin.debug(String.format("[%s] Set %s key on Redis", user.getName(), RedisKeyType.LATEST_SNAPSHOT)); } catch (Throwable e) { plugin.log(Level.SEVERE, "An exception occurred setting user data on Redis", e); } @@ -273,7 +274,7 @@ public class RedisManager extends JedisPubSub { jedis.del( getKey(RedisKeyType.LATEST_SNAPSHOT, user.getUuid(), clusterId) ); - plugin.debug(String.format("[%s] Cleared %s on Redis", user.getUsername(), RedisKeyType.LATEST_SNAPSHOT)); + plugin.debug(String.format("[%s] Cleared %s on Redis", user.getName(), RedisKeyType.LATEST_SNAPSHOT)); } catch (Throwable e) { plugin.log(Level.SEVERE, "An exception occurred clearing user data on Redis", e); } @@ -291,11 +292,11 @@ public class RedisManager extends JedisPubSub { } else { if (jedis.del(key.getBytes(StandardCharsets.UTF_8)) == 0) { plugin.debug(String.format("[%s] %s key not set on Redis when attempting removal (%s)", - user.getUsername(), RedisKeyType.DATA_CHECKOUT, key)); + user.getName(), RedisKeyType.DATA_CHECKOUT, key)); return; } } - plugin.debug(String.format("[%s] %s %s key %s Redis (%s)", user.getUsername(), + plugin.debug(String.format("[%s] %s %s key %s Redis (%s)", user.getName(), checkedOut ? "Set" : "Removed", RedisKeyType.DATA_CHECKOUT, checkedOut ? "to" : "from", key)); } catch (Throwable e) { plugin.log(Level.SEVERE, "An exception occurred setting checkout to", e); @@ -310,13 +311,13 @@ public class RedisManager extends JedisPubSub { if (readData != null) { final String checkoutServer = new String(readData, StandardCharsets.UTF_8); plugin.debug(String.format("[%s] Waiting for %s %s key to be unset on Redis", - user.getUsername(), checkoutServer, RedisKeyType.DATA_CHECKOUT)); + user.getName(), checkoutServer, RedisKeyType.DATA_CHECKOUT)); return Optional.of(checkoutServer); } } catch (Throwable e) { plugin.log(Level.SEVERE, "An exception occurred getting a user's checkout key from Redis", e); } - plugin.debug(String.format("[%s] %s key not set on Redis", user.getUsername(), + plugin.debug(String.format("[%s] %s key not set on Redis", user.getName(), RedisKeyType.DATA_CHECKOUT)); return Optional.empty(); } @@ -354,7 +355,7 @@ public class RedisManager extends JedisPubSub { new byte[0] ); plugin.debug(String.format("[%s] Set %s key to Redis", - user.getUsername(), RedisKeyType.SERVER_SWITCH)); + user.getName(), RedisKeyType.SERVER_SWITCH)); } catch (Throwable e) { plugin.log(Level.SEVERE, "An exception occurred setting a user's server switch key from Redis", e); } @@ -373,11 +374,11 @@ public class RedisManager extends JedisPubSub { final byte[] dataByteArray = jedis.get(key); if (dataByteArray == null) { plugin.debug(String.format("[%s] Waiting for %s key from Redis", - user.getUsername(), RedisKeyType.LATEST_SNAPSHOT)); + user.getName(), RedisKeyType.LATEST_SNAPSHOT)); return Optional.empty(); } plugin.debug(String.format("[%s] Read %s key from Redis", - user.getUsername(), RedisKeyType.LATEST_SNAPSHOT)); + user.getName(), RedisKeyType.LATEST_SNAPSHOT)); // Consume the key (delete from redis) jedis.del(key); @@ -397,11 +398,11 @@ public class RedisManager extends JedisPubSub { final byte[] readData = jedis.get(key); if (readData == null) { plugin.debug(String.format("[%s] Waiting for %s key from Redis", - user.getUsername(), RedisKeyType.SERVER_SWITCH)); + user.getName(), RedisKeyType.SERVER_SWITCH)); return false; } plugin.debug(String.format("[%s] Read %s key from Redis", - user.getUsername(), RedisKeyType.SERVER_SWITCH)); + user.getName(), RedisKeyType.SERVER_SWITCH)); // Consume the key (delete from redis) jedis.del(key); @@ -439,6 +440,97 @@ public class RedisManager extends JedisPubSub { return "unknown"; } + @Blocking + public void bindMapIds(@NotNull String fromServer, int fromId, @NotNull String toServer, int toId) { + try (Jedis jedis = jedisPool.getResource()) { + jedis.setex( + getMapIdKey(fromServer, fromId, toServer, clusterId), + RedisKeyType.TTL_1_YEAR, + String.valueOf(toId).getBytes(StandardCharsets.UTF_8) + ); + jedis.setex( + getReversedMapIdKey(toServer, toId, clusterId), + RedisKeyType.TTL_1_YEAR, + String.format("%s:%s", fromServer, fromId).getBytes(StandardCharsets.UTF_8) + ); + plugin.debug(String.format("Bound map %s:%s -> %s:%s on Redis", fromServer, fromId, toServer, toId)); + } catch (Throwable e) { + plugin.log(Level.SEVERE, "An exception occurred binding map ids on Redis", e); + } + } + + @Blocking + public Optional getBoundMapId(@NotNull String fromServer, int fromId, @NotNull String toServer) { + try (Jedis jedis = jedisPool.getResource()) { + final byte[] readData = jedis.get(getMapIdKey(fromServer, fromId, toServer, clusterId)); + if (readData == null) { + plugin.debug(String.format("[%s:%s] No bound map id for server %s Redis", + fromServer, fromId, toServer)); + return Optional.empty(); + } + plugin.debug(String.format("[%s:%s] Read bound map id for server %s from Redis", + fromServer, fromId, toServer)); + + return Optional.of(Integer.parseInt(new String(readData, StandardCharsets.UTF_8))); + } catch (Throwable e) { + plugin.log(Level.SEVERE, "An exception occurred getting bound map id from Redis", e); + return Optional.empty(); + } + } + + @Blocking + public @Nullable Map.Entry getReversedMapBound(@NotNull String toServer, int toId) { + try (Jedis jedis = jedisPool.getResource()) { + final byte[] readData = jedis.get(getReversedMapIdKey(toServer, toId, clusterId)); + if (readData == null) { + plugin.debug(String.format("[%s:%s] No reversed map bound on Redis", + toServer, toId)); + return null; + } + plugin.debug(String.format("[%s:%s] Read reversed map bound from Redis", + toServer, toId)); + + String[] parts = new String(readData, StandardCharsets.UTF_8).split(":"); + return Map.entry(parts[0], Integer.parseInt(parts[1])); + } catch (Throwable e) { + plugin.log(Level.SEVERE, "An exception occurred reading reversed map bound from Redis", e); + return null; + } + } + + @Blocking + public void setMapData(@NotNull String serverName, int mapId, byte[] data) { + try (Jedis jedis = jedisPool.getResource()) { + jedis.setex( + getMapDataKey(serverName, mapId, clusterId), + RedisKeyType.TTL_1_YEAR, + data + ); + plugin.debug(String.format("Set map data %s:%s on Redis", serverName, mapId)); + } catch (Throwable e) { + plugin.log(Level.SEVERE, "An exception occurred setting map data on Redis", e); + } + } + + @Blocking + public byte @Nullable [] getMapData(@NotNull String serverName, int mapId) { + try (Jedis jedis = jedisPool.getResource()) { + final byte[] readData = jedis.get(getMapDataKey(serverName, mapId, clusterId)); + if (readData == null) { + plugin.debug(String.format("[%s:%s] No map data on Redis", + serverName, mapId)); + return null; + } + plugin.debug(String.format("[%s:%s] Read map data from Redis", + serverName, mapId)); + + return readData; + } catch (Throwable e) { + plugin.log(Level.SEVERE, "An exception occurred reading map data from Redis", e); + return null; + } + } + @Blocking public void terminate() { enabled = false; @@ -459,4 +551,16 @@ public class RedisManager extends JedisPubSub { return String.format("%s:%s", keyType.getKeyPrefix(clusterId), uuid); } + private static byte[] getMapIdKey(@NotNull String fromServer, int fromId, @NotNull String toServer, @NotNull String clusterId) { + return String.format("%s:%s:%s:%s", RedisKeyType.MAP_ID.getKeyPrefix(clusterId), fromServer, fromId, toServer).getBytes(StandardCharsets.UTF_8); + } + + private static byte[] getReversedMapIdKey(@NotNull String toServer, int toId, @NotNull String clusterId) { + return String.format("%s:%s:%s", RedisKeyType.MAP_ID_REVERSED.getKeyPrefix(clusterId), toServer, toId).getBytes(StandardCharsets.UTF_8); + } + + private static byte[] getMapDataKey(@NotNull String serverName, int mapId, @NotNull String clusterId) { + return String.format("%s:%s:%s", RedisKeyType.MAP_DATA.getKeyPrefix(clusterId), serverName, mapId).getBytes(StandardCharsets.UTF_8); + } + } diff --git a/common/src/main/java/net/william278/husksync/sync/DataSyncer.java b/common/src/main/java/net/william278/husksync/sync/DataSyncer.java index 749f5c41..0d7d0e57 100644 --- a/common/src/main/java/net/william278/husksync/sync/DataSyncer.java +++ b/common/src/main/java/net/william278/husksync/sync/DataSyncer.java @@ -163,7 +163,7 @@ public abstract class DataSyncer { () -> user.completeSync(true, DataSnapshot.UpdateCause.NEW_USER, plugin) ); } catch (Throwable e) { - plugin.log(Level.WARNING, "Failed to set %s's data from the database".formatted(user.getUsername()), e); + plugin.log(Level.WARNING, "Failed to set %s's data from the database".formatted(user.getName()), e); user.completeSync(false, DataSnapshot.UpdateCause.SYNCHRONIZED, plugin); } } @@ -188,7 +188,7 @@ public abstract class DataSyncer { if (plugin.isDisabling() || timesRun.getAndIncrement() > maxListenAttempts) { task.get().cancel(); plugin.debug(String.format("[%s] Redis timed out after %s attempts; setting from database", - user.getUsername(), timesRun.get())); + user.getName(), timesRun.get())); setUserFromDatabase(user); return; } diff --git a/common/src/main/java/net/william278/husksync/user/OnlineUser.java b/common/src/main/java/net/william278/husksync/user/OnlineUser.java index c64a4881..f0d86645 100644 --- a/common/src/main/java/net/william278/husksync/user/OnlineUser.java +++ b/common/src/main/java/net/william278/husksync/user/OnlineUser.java @@ -127,7 +127,7 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo getPlugin().fireEvent(getPlugin().getPreSyncEvent(this, snapshot), (event) -> { if (!isOffline()) { getPlugin().debug(String.format("Applying snapshot (%s) to %s (cause: %s)", - snapshot.getShortId(), getUsername(), cause.getDisplayName() + snapshot.getShortId(), getName(), cause.getDisplayName() )); UserDataHolder.super.applySnapshot( event.getData(), (succeeded) -> completeSync(succeeded, cause, getPlugin()) diff --git a/common/src/main/java/net/william278/husksync/util/DataSnapshotList.java b/common/src/main/java/net/william278/husksync/util/DataSnapshotList.java index 9b2c8eb0..c2419076 100644 --- a/common/src/main/java/net/william278/husksync/util/DataSnapshotList.java +++ b/common/src/main/java/net/william278/husksync/util/DataSnapshotList.java @@ -49,7 +49,7 @@ public class DataSnapshotList { .map(snapshot -> plugin.getLocales() .getRawLocale(!snapshot.isInvalid() ? "data_list_item" : "data_list_item_invalid", getNumberIcon(snapshotNumber.getAndIncrement()), - dataOwner.getUsername(), + dataOwner.getName(), snapshot.getId().toString(), snapshot.getShortId(), snapshot.isPinned() ? "※" : " ", @@ -63,10 +63,10 @@ public class DataSnapshotList { .orElse("• " + snapshot.getId())).toList(), plugin.getLocales().getBaseChatList(6) .setHeaderFormat(plugin.getLocales() - .getRawLocale("data_list_title", dataOwner.getUsername(), + .getRawLocale("data_list_title", dataOwner.getName(), "%first_item_on_page_index%", "%last_item_on_page_index%", "%total_items%") .orElse("")) - .setCommand("/husksync:userdata list " + dataOwner.getUsername()) + .setCommand("/husksync:userdata list " + dataOwner.getName()) .build()); } diff --git a/common/src/main/java/net/william278/husksync/util/DataSnapshotOverview.java b/common/src/main/java/net/william278/husksync/util/DataSnapshotOverview.java index 582a37b9..d7497e8a 100644 --- a/common/src/main/java/net/william278/husksync/util/DataSnapshotOverview.java +++ b/common/src/main/java/net/william278/husksync/util/DataSnapshotOverview.java @@ -59,7 +59,7 @@ public class DataSnapshotOverview { // Title message, timestamp, owner and cause. final Locales locales = plugin.getLocales(); locales.getLocale("data_manager_title", snapshot.getShortId(), snapshot.getId().toString(), - dataOwner.getUsername(), dataOwner.getUuid().toString()) + dataOwner.getName(), dataOwner.getUuid().toString()) .ifPresent(user::sendMessage); locales.getLocale("data_manager_timestamp", snapshot.getTimestamp().format(DateTimeFormatter @@ -107,13 +107,13 @@ public class DataSnapshotOverview { if (user.hasPermission("husksync.command.inventory.edit") && user.hasPermission("husksync.command.enderchest.edit")) { - locales.getLocale("data_manager_item_buttons", dataOwner.getUsername(), snapshot.getId().toString()) + locales.getLocale("data_manager_item_buttons", dataOwner.getName(), snapshot.getId().toString()) .ifPresent(user::sendMessage); } - locales.getLocale("data_manager_management_buttons", dataOwner.getUsername(), snapshot.getId().toString()) + locales.getLocale("data_manager_management_buttons", dataOwner.getName(), snapshot.getId().toString()) .ifPresent(user::sendMessage); if (user.hasPermission("husksync.command.userdata.dump")) { - locales.getLocale("data_manager_system_buttons", dataOwner.getUsername(), snapshot.getId().toString()) + locales.getLocale("data_manager_system_buttons", dataOwner.getName(), snapshot.getId().toString()) .ifPresent(user::sendMessage); } } diff --git a/common/src/main/java/net/william278/husksync/util/UserDataDumper.java b/common/src/main/java/net/william278/husksync/util/UserDataDumper.java index 4e1c2686..0fc488eb 100644 --- a/common/src/main/java/net/william278/husksync/util/UserDataDumper.java +++ b/common/src/main/java/net/william278/husksync/util/UserDataDumper.java @@ -179,7 +179,7 @@ public class UserDataDumper { @NotNull private String getFileName() { return new StringJoiner("_") - .add(user.getUsername()) + .add(user.getName()) .add(snapshot.getTimestamp().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"))) .add(snapshot.getSaveCause().name().toLowerCase(Locale.ENGLISH)) .add(snapshot.getShortId()) diff --git a/common/src/main/resources/database/mariadb_schema.sql b/common/src/main/resources/database/mariadb_schema.sql index bd851c11..793219f4 100644 --- a/common/src/main/resources/database/mariadb_schema.sql +++ b/common/src/main/resources/database/mariadb_schema.sql @@ -29,4 +29,28 @@ CREATE TABLE IF NOT EXISTS `%user_data_table%` FOREIGN KEY (`player_uuid`) REFERENCES `%users_table%` (`uuid`) ON DELETE CASCADE ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 - COLLATE = utf8mb4_unicode_ci; \ No newline at end of file + COLLATE = utf8mb4_unicode_ci; + +-- Create the map data table if it does not exist +CREATE TABLE IF NOT EXISTS `%map_data_table%` +( + `server_name` varchar(32) NOT NULL, + `map_id` int NOT NULL, + `data` longblob NOT NULL, + PRIMARY KEY (`server_name`, `map_id`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +-- Create the map ids table if it does not exist +CREATE TABLE IF NOT EXISTS `%map_ids_table%` +( + `from_server_name` varchar(32) NOT NULL, + `from_id` int NOT NULL, + `to_server_name` varchar(32) NOT NULL, + `to_id` int NOT NULL, + PRIMARY KEY (`from_server_name`, `from_id`, `to_server_name`), + FOREIGN KEY (`from_server_name`, `from_id`) REFERENCES `%map_data_table%` (`server_name`, `map_id`) ON DELETE CASCADE +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; diff --git a/common/src/main/resources/database/mysql_schema.sql b/common/src/main/resources/database/mysql_schema.sql index efef8797..e02597cd 100644 --- a/common/src/main/resources/database/mysql_schema.sql +++ b/common/src/main/resources/database/mysql_schema.sql @@ -26,4 +26,26 @@ CREATE TABLE IF NOT EXISTS `%user_data_table%` PRIMARY KEY (`version_uuid`, `player_uuid`), FOREIGN KEY (`player_uuid`) REFERENCES `%users_table%` (`uuid`) ON DELETE CASCADE ) CHARACTER SET utf8 - COLLATE utf8_unicode_ci; \ No newline at end of file + COLLATE utf8_unicode_ci; + +# Create the map data table if it does not exist +CREATE TABLE IF NOT EXISTS `%map_data_table%` +( + `server_name` varchar(32) NOT NULL, + `map_id` int NOT NULL, + `data` longblob NOT NULL, + PRIMARY KEY (`server_name`, `map_id`) +) CHARACTER SET utf8 + COLLATE utf8_unicode_ci; + +# Create the map ids table if it does not exist +CREATE TABLE IF NOT EXISTS `%map_ids_table%` +( + `from_server_name` varchar(32) NOT NULL, + `from_id` int NOT NULL, + `to_server_name` varchar(32) NOT NULL, + `to_id` int NOT NULL, + PRIMARY KEY (`from_server_name`, `from_id`, `to_server_name`), + FOREIGN KEY (`from_server_name`, `from_id`) REFERENCES `%map_data_table%` (`server_name`, `map_id`) ON DELETE CASCADE +) CHARACTER SET utf8 + COLLATE utf8_unicode_ci; diff --git a/common/src/main/resources/database/postgresql_schema.sql b/common/src/main/resources/database/postgresql_schema.sql index 5b4b8ee9..615ab632 100644 --- a/common/src/main/resources/database/postgresql_schema.sql +++ b/common/src/main/resources/database/postgresql_schema.sql @@ -19,4 +19,24 @@ CREATE TABLE IF NOT EXISTS "%user_data_table%" PRIMARY KEY (version_uuid, player_uuid), FOREIGN KEY (player_uuid) REFERENCES "%users_table%" (uuid) ON DELETE CASCADE -); \ No newline at end of file +); + +-- Create the map data table if it does not exist +CREATE TABLE IF NOT EXISTS "%map_data_table%" +( + server_name varchar(32) NOT NULL, + map_id int NOT NULL, + data bytea NOT NULL, + PRIMARY KEY (server_name, map_id) +); + +-- Create the map ids table if it does not exist +CREATE TABLE IF NOT EXISTS "%map_ids_table%" +( + from_server_name varchar(32) NOT NULL, + from_id int NOT NULL, + to_server_name varchar(32) NOT NULL, + to_id int NOT NULL, + PRIMARY KEY (from_server_name, from_id, to_server_name), + FOREIGN KEY (from_server_name, from_id) REFERENCES "%map_data_table%" (server_name, map_id) ON DELETE CASCADE +); diff --git a/docs/Data-Snapshot-API.md b/docs/Data-Snapshot-API.md index 118ca886..e0d6b2c4 100644 --- a/docs/Data-Snapshot-API.md +++ b/docs/Data-Snapshot-API.md @@ -37,7 +37,7 @@ huskSyncAPI.getUser(uuid).thenAccept(optionalUser -> { } // The User object provides methods for getting a user's UUID and username - System.out.println("Found %s", optionalUser.get().getUsername()); + System.out.println("Found %s", optionalUser.get().getName()); }); ``` @@ -51,7 +51,7 @@ huskSyncAPI.getUser(uuid).thenAccept(optionalUser -> { ```java // Get an online user OnlineUser user = huskSyncAPI.getUser(player); -System.out.println("Hello, %s!", user.getUsername()); +System.out.println("Hello, %s!", user.getName()); ``` @@ -67,7 +67,7 @@ System.out.println("Hello, %s!", user.getUsername()); // Get a user's current data huskSyncAPI.getCurrentData(user).thenAccept(optionalSnapshot -> { if (optionalSnapshot.isEmpty()) { - System.out.println("Couldn't get data for %s", user.getUsername()); + System.out.println("Couldn't get data for %s", user.getName()); return; } @@ -88,7 +88,7 @@ huskSyncAPI.getCurrentData(user).thenAccept(optionalSnapshot -> { // Get a user's latest saved snapshot huskSyncAPI.getLatestSnapshot(user).thenAccept(optionalSnapshot -> { if (optionalSnapshot.isEmpty()) { - System.out.println("%s has no saved snapshots!", user.getUsername()); + System.out.println("%s has no saved snapshots!", user.getName()); return; } @@ -108,7 +108,7 @@ huskSyncAPI.getLatestSnapshot(user).thenAccept(optionalSnapshot -> { // Get a user's saved snapshots huskSyncAPI.getSnapshots(user).thenAccept(optionalSnapshots -> { if (optionalSnapshots.isEmpty()) { - System.out.println("%s has no saved snapshots!", user.getUsername()); + System.out.println("%s has no saved snapshots!", user.getName()); return; } diff --git a/test/config.yml b/test/config.yml index 28a7df3d..80e50a60 100644 --- a/test/config.yml +++ b/test/config.yml @@ -9,7 +9,7 @@ check_for_updates: false cluster_id: '' # Enable development debug logging -debug_logging: false +debug_logging: true # Whether to enable the Player Analytics hook. # Docs: https://william278.net/docs/husksync/plan-hook