Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework locked maps syncing #441

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ public void onWorldSave(@NotNull WorldSaveEvent event) {
@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).renderMapFromDb(event.getMap()));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@
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.data.AdaptableMapData;
import net.william278.mapdataapi.MapBanner;
import net.william278.mapdataapi.MapData;
import org.bukkit.Bukkit;
Expand All @@ -35,14 +34,15 @@
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.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;
Expand All @@ -52,10 +52,10 @@ public interface BukkitMapPersister {

// 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
Expand Down Expand Up @@ -99,11 +99,41 @@ private ItemStack[] forEachMap(ItemStack[] items, @NotNull Function<ItemStack, I
} 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;
}

private void writeMapData(@NotNull String serverName, int mapId, MapData data) {
getPlugin().getDatabase().writeMapData(
serverName,
mapId,
getPlugin().getDataAdapter().toBytes(new AdaptableMapData(data))
);
}

private Map.Entry<MapData, Boolean> readMapData(@NotNull String serverName, int mapId) {
Map.Entry<byte[], Boolean> result = getPlugin().getDatabase().readMapData(serverName, mapId);
if (result == null) {
return null;
}
try {
return Map.entry(
getPlugin().getDataAdapter().fromBytes(result.getKey(), AdaptableMapData.class)
.getData(getPlugin().getDataVersion(getPlugin().getMinecraftVersion())),
result.getValue()
);
} catch (IOException e) {
getPlugin().log(Level.WARNING, "Failed to deserialize map data", e);
return null;
}
}

@SuppressWarnings("deprecation")
@NotNull
private ItemStack persistMapView(@NotNull ItemStack map, @NotNull Player delegateRenderer) {
final MapMeta meta = Objects.requireNonNull((MapMeta) map.getItemMeta());
Expand Down Expand Up @@ -131,95 +161,84 @@ private ItemStack persistMapView(@NotNull ItemStack map, @NotNull Player delegat

// 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<String> 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<MapView> 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;
}
final String originServerName = mapData.getString(MAP_ORIGIN_KEY);
final String currentServerName = getPlugin().getServerName();
final int originalMapId = mapData.getInteger(MAP_ID_KEY);
int newId;
if (currentServerName.equals(originServerName)) {
newId = originalMapId;
} else {
newId = getPlugin().getDatabase().getNewMapId(
originServerName,
originalMapId,
currentServerName
);
}

// 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);
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
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));
getPlugin().getDatabase().bindMapIds(originServerName, originalMapId, currentServerName, view.getId());

getPlugin().debug(String.format("Bound map to view (#%s) on server %s", view.getId(), currentServerName));
});
return map;
}

default void renderMapFromFile(@NotNull MapView view) {
final File mapFile = new File(getMapCacheFolder(), view.getId() + ".dat");
if (!mapFile.exists()) {
default void renderMapFromDb(@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 Map.Entry<MapData, Boolean> data = readMapData(getPlugin().getServerName(), view.getId());
if (data == null) {
getPlugin().log(Level.WARNING, "Cannot render map: no data in DB for world " + getDefaultMapWorld().getUID() + ", map " + 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);
Expand All @@ -233,32 +252,6 @@ default void renderMapFromFile(@NotNull MapView view) {
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) {
Expand Down
1 change: 1 addition & 0 deletions common/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ dependencies {
api 'commons-io:commons-io:2.18.0'
api 'org.apache.commons:commons-text:1.12.0'
api 'net.william278:minedown:1.8.2'
api 'net.william278:mapdataapi:2.0'
api 'org.json:json:20240303'
api 'com.google.code.gson:gson:2.11.0'
api 'com.fatboyindustrial.gson-javatime-serialisers:gson-javatime-serialisers:1.1.2'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package net.william278.husksync.data;

import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.NotNull;
import net.william278.husksync.adapter.Adaptable;
import net.william278.mapdataapi.MapData;

import java.io.IOException;

public class AdaptableMapData implements Adaptable {
@SerializedName("data")
private final byte[] data;

public AdaptableMapData(byte @NotNull [] data) {
this.data = data;
}

public AdaptableMapData(MapData data) {
this(data.toBytes());
}

public MapData getData(int dataVersion) throws IOException {
return MapData.fromByteArray(dataVersion, data);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
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;
Expand Down Expand Up @@ -56,8 +57,8 @@ protected Database(@NotNull HuskSync plugin) {
@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);
}

/**
Expand All @@ -70,7 +71,9 @@ protected final String[] getSchemaStatements(@NotNull String schemaFileName) thr
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));
}

/**
Expand Down Expand Up @@ -246,6 +249,48 @@ public final void pinSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
});
}

/**
* 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 writeMapData(@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<byte[], Boolean> readMapData(@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 bindMapIds(@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 getNewMapId(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName);

/**
* Wipes <b>all</b> {@link User} entries from the database.
* <b>This should only be used when preparing tables for a data migration.</b>
Expand Down Expand Up @@ -283,7 +328,9 @@ public enum Type {
@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;

Expand Down
Loading
Loading