mirror of
https://github.com/WiIIiam278/HuskSync.git
synced 2025-12-27 10:39:11 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c406f40898 | ||
|
|
7561762c25 | ||
|
|
d245245083 | ||
|
|
2b55e129b3 | ||
|
|
0caec74436 | ||
|
|
55e443cd49 | ||
|
|
b63e1bd283 | ||
|
|
575122e6dd | ||
|
|
856cbb9caa | ||
|
|
7034a97d3a | ||
|
|
635edb930f | ||
|
|
1d3e4b7a20 |
@@ -10,7 +10,7 @@ dependencies {
|
|||||||
implementation 'net.kyori:adventure-platform-bukkit:4.3.0'
|
implementation 'net.kyori:adventure-platform-bukkit:4.3.0'
|
||||||
implementation 'dev.triumphteam:triumph-gui:3.1.5'
|
implementation 'dev.triumphteam:triumph-gui:3.1.5'
|
||||||
implementation 'space.arim.morepaperlib:morepaperlib:0.4.3'
|
implementation 'space.arim.morepaperlib:morepaperlib:0.4.3'
|
||||||
implementation 'de.tr7zw:item-nbt-api:2.12.0-SNAPSHOT'
|
implementation 'de.tr7zw:item-nbt-api:2.12.0'
|
||||||
|
|
||||||
compileOnly 'org.spigotmc:spigot-api:1.16.5-R0.1-SNAPSHOT'
|
compileOnly 'org.spigotmc:spigot-api:1.16.5-R0.1-SNAPSHOT'
|
||||||
compileOnly 'commons-io:commons-io:2.13.0'
|
compileOnly 'commons-io:commons-io:2.13.0'
|
||||||
@@ -34,7 +34,7 @@ shadowJar {
|
|||||||
relocate 'org.apache.commons.lang3', 'net.william278.husksync.libraries.commons.lang3'
|
relocate 'org.apache.commons.lang3', 'net.william278.husksync.libraries.commons.lang3'
|
||||||
relocate 'com.google.gson', 'net.william278.husksync.libraries.gson'
|
relocate 'com.google.gson', 'net.william278.husksync.libraries.gson'
|
||||||
relocate 'org.json', 'net.william278.husksync.libraries.json'
|
relocate 'org.json', 'net.william278.husksync.libraries.json'
|
||||||
relocate 'com.fatboyindustrial', 'net.william278.husktowns.libraries'
|
relocate 'com.fatboyindustrial', 'net.william278.husksync.libraries'
|
||||||
relocate 'de.themoep', 'net.william278.husksync.libraries'
|
relocate 'de.themoep', 'net.william278.husksync.libraries'
|
||||||
relocate 'net.kyori', 'net.william278.husksync.libraries'
|
relocate 'net.kyori', 'net.william278.husksync.libraries'
|
||||||
relocate 'org.jetbrains', 'net.william278.husksync.libraries'
|
relocate 'org.jetbrains', 'net.william278.husksync.libraries'
|
||||||
|
|||||||
@@ -28,10 +28,7 @@ import net.william278.husksync.HuskSync;
|
|||||||
import net.william278.husksync.adapter.Adaptable;
|
import net.william278.husksync.adapter.Adaptable;
|
||||||
import net.william278.husksync.user.BukkitUser;
|
import net.william278.husksync.user.BukkitUser;
|
||||||
import org.apache.commons.lang.NotImplementedException;
|
import org.apache.commons.lang.NotImplementedException;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.*;
|
||||||
import org.bukkit.GameRule;
|
|
||||||
import org.bukkit.Material;
|
|
||||||
import org.bukkit.Statistic;
|
|
||||||
import org.bukkit.advancement.AdvancementProgress;
|
import org.bukkit.advancement.AdvancementProgress;
|
||||||
import org.bukkit.attribute.Attribute;
|
import org.bukkit.attribute.Attribute;
|
||||||
import org.bukkit.attribute.AttributeInstance;
|
import org.bukkit.attribute.AttributeInstance;
|
||||||
@@ -53,14 +50,8 @@ import java.util.stream.Collectors;
|
|||||||
public abstract class BukkitData implements Data {
|
public abstract class BukkitData implements Data {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public final void apply(@NotNull UserDataHolder dataHolder, @NotNull HuskSync plugin) {
|
public final void apply(@NotNull UserDataHolder dataHolder, @NotNull HuskSync plugin) throws IllegalStateException {
|
||||||
final BukkitUser user = (BukkitUser) dataHolder;
|
this.apply((BukkitUser) dataHolder, (BukkitHuskSync) plugin);
|
||||||
try {
|
|
||||||
this.apply(user, (BukkitHuskSync) plugin);
|
|
||||||
} catch (Throwable e) {
|
|
||||||
plugin.log(Level.WARNING, String.format("[%s] Failed to apply %s data object; skipping",
|
|
||||||
user.getUsername(), this.getClass().getSimpleName()), e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract void apply(@NotNull BukkitUser user, @NotNull BukkitHuskSync plugin) throws IllegalStateException;
|
public abstract void apply(@NotNull BukkitUser user, @NotNull BukkitHuskSync plugin) throws IllegalStateException;
|
||||||
@@ -104,14 +95,9 @@ public abstract class BukkitData implements Data {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setContents(@NotNull Data.Items contents) {
|
public void setContents(@NotNull Data.Items contents) {
|
||||||
System.arraycopy(
|
this.setContents(((BukkitData.Items) contents).getContents());
|
||||||
((BukkitData.Items) contents).getContents(),
|
|
||||||
0, this.contents,
|
|
||||||
0, this.contents.length
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
|
||||||
public void setContents(@NotNull ItemStack[] contents) {
|
public void setContents(@NotNull ItemStack[] contents) {
|
||||||
System.arraycopy(contents, 0, this.contents, 0, this.contents.length);
|
System.arraycopy(contents, 0, this.contents, 0, this.contents.length);
|
||||||
}
|
}
|
||||||
@@ -535,7 +521,7 @@ public abstract class BukkitData implements Data {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static class Statistics extends BukkitData implements Data.Statistics {
|
public static class Statistics extends BukkitData implements Data.Statistics {
|
||||||
private Map<Statistic, Integer> untypedStatistics;
|
private Map<Statistic, Integer> genericStatistics;
|
||||||
private Map<Statistic, Map<Material, Integer>> blockStatistics;
|
private Map<Statistic, Map<Material, Integer>> blockStatistics;
|
||||||
private Map<Statistic, Map<Material, Integer>> itemStatistics;
|
private Map<Statistic, Map<Material, Integer>> itemStatistics;
|
||||||
private Map<Statistic, Map<EntityType, Integer>> entityStatistics;
|
private Map<Statistic, Map<EntityType, Integer>> entityStatistics;
|
||||||
@@ -544,7 +530,7 @@ public abstract class BukkitData implements Data {
|
|||||||
@NotNull Map<Statistic, Map<Material, Integer>> blockStatistics,
|
@NotNull Map<Statistic, Map<Material, Integer>> blockStatistics,
|
||||||
@NotNull Map<Statistic, Map<Material, Integer>> itemStatistics,
|
@NotNull Map<Statistic, Map<Material, Integer>> itemStatistics,
|
||||||
@NotNull Map<Statistic, Map<EntityType, Integer>> entityStatistics) {
|
@NotNull Map<Statistic, Map<EntityType, Integer>> entityStatistics) {
|
||||||
this.untypedStatistics = genericStatistics;
|
this.genericStatistics = genericStatistics;
|
||||||
this.blockStatistics = blockStatistics;
|
this.blockStatistics = blockStatistics;
|
||||||
this.itemStatistics = itemStatistics;
|
this.itemStatistics = itemStatistics;
|
||||||
this.entityStatistics = entityStatistics;
|
this.entityStatistics = entityStatistics;
|
||||||
@@ -665,7 +651,7 @@ public abstract class BukkitData implements Data {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void apply(@NotNull BukkitUser user, @NotNull BukkitHuskSync plugin) throws IllegalStateException {
|
public void apply(@NotNull BukkitUser user, @NotNull BukkitHuskSync plugin) throws IllegalStateException {
|
||||||
untypedStatistics.forEach((stat, value) -> applyStat(user, stat, null, value));
|
genericStatistics.forEach((stat, value) -> applyStat(user, stat, null, value));
|
||||||
blockStatistics.forEach((stat, m) -> m.forEach((block, value) -> applyStat(user, stat, block, value)));
|
blockStatistics.forEach((stat, m) -> m.forEach((block, value) -> applyStat(user, stat, block, value)));
|
||||||
itemStatistics.forEach((stat, m) -> m.forEach((item, value) -> applyStat(user, stat, item, value)));
|
itemStatistics.forEach((stat, m) -> m.forEach((item, value) -> applyStat(user, stat, item, value)));
|
||||||
entityStatistics.forEach((stat, m) -> m.forEach((entity, value) -> applyStat(user, stat, entity, value)));
|
entityStatistics.forEach((stat, m) -> m.forEach((entity, value) -> applyStat(user, stat, entity, value)));
|
||||||
@@ -688,45 +674,41 @@ public abstract class BukkitData implements Data {
|
|||||||
@NotNull
|
@NotNull
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Integer> getGenericStatistics() {
|
public Map<String, Integer> getGenericStatistics() {
|
||||||
return untypedStatistics.entrySet().stream().collect(
|
return convertStatistics(genericStatistics);
|
||||||
TreeMap::new,
|
|
||||||
(m, e) -> m.put(e.getKey().getKey().toString(), e.getValue()), TreeMap::putAll
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Map<String, Integer>> getBlockStatistics() {
|
public Map<String, Map<String, Integer>> getBlockStatistics() {
|
||||||
return blockStatistics.entrySet().stream().collect(
|
return blockStatistics.entrySet().stream().filter(entry -> entry.getKey() != null).collect(
|
||||||
TreeMap::new,
|
TreeMap::new,
|
||||||
(m, e) -> m.put(e.getKey().getKey().toString(), e.getValue().entrySet().stream().collect(
|
(m, e) -> m.put(e.getKey().getKey().toString(), convertStatistics(e.getValue())), TreeMap::putAll
|
||||||
TreeMap::new,
|
|
||||||
(m2, e2) -> m2.put(e2.getKey().getKey().toString(), e2.getValue()), TreeMap::putAll
|
|
||||||
)), TreeMap::putAll
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Map<String, Integer>> getItemStatistics() {
|
public Map<String, Map<String, Integer>> getItemStatistics() {
|
||||||
return itemStatistics.entrySet().stream().collect(
|
return itemStatistics.entrySet().stream().filter(entry -> entry.getKey() != null).collect(
|
||||||
TreeMap::new,
|
TreeMap::new,
|
||||||
(m, e) -> m.put(e.getKey().getKey().toString(), e.getValue().entrySet().stream().collect(
|
(m, e) -> m.put(e.getKey().getKey().toString(), convertStatistics(e.getValue())), TreeMap::putAll
|
||||||
TreeMap::new,
|
|
||||||
(m2, e2) -> m2.put(e2.getKey().getKey().toString(), e2.getValue()), TreeMap::putAll
|
|
||||||
)), TreeMap::putAll
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@Override
|
@Override
|
||||||
public Map<String, Map<String, Integer>> getEntityStatistics() {
|
public Map<String, Map<String, Integer>> getEntityStatistics() {
|
||||||
return entityStatistics.entrySet().stream().collect(
|
return entityStatistics.entrySet().stream().filter(entry -> entry.getKey() != null).collect(
|
||||||
TreeMap::new,
|
TreeMap::new,
|
||||||
(m, e) -> m.put(e.getKey().getKey().toString(), e.getValue().entrySet().stream().collect(
|
(m, e) -> m.put(e.getKey().getKey().toString(), convertStatistics(e.getValue())), TreeMap::putAll
|
||||||
TreeMap::new,
|
);
|
||||||
(m2, e2) -> m2.put(e2.getKey().getKey().toString(), e2.getValue()), TreeMap::putAll
|
}
|
||||||
)), TreeMap::putAll
|
|
||||||
|
@NotNull
|
||||||
|
private <T extends Keyed> Map<String, Integer> convertStatistics(@NotNull Map<T, Integer> stats) {
|
||||||
|
return stats.entrySet().stream().filter(entry -> entry.getKey() != null).collect(
|
||||||
|
TreeMap::new,
|
||||||
|
(m, e) -> m.put(e.getKey().getKey().toString(), e.getValue()), TreeMap::putAll
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,11 +790,11 @@ public abstract class BukkitData implements Data {
|
|||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
public static BukkitData.Health adapt(@NotNull Player player) {
|
public static BukkitData.Health adapt(@NotNull Player player) {
|
||||||
|
final double maxHealth = getMaxHealth(player);
|
||||||
return from(
|
return from(
|
||||||
player.getHealth(),
|
Math.min(player.getHealth(), maxHealth),
|
||||||
Objects.requireNonNull(player.getAttribute(Attribute.GENERIC_MAX_HEALTH),
|
maxHealth,
|
||||||
"Missing max health attribute").getValue(),
|
player.isHealthScaled() ? player.getHealthScale() : 0d
|
||||||
player.getHealthScale()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,9 +804,10 @@ public abstract class BukkitData implements Data {
|
|||||||
|
|
||||||
// Set base max health
|
// Set base max health
|
||||||
final AttributeInstance maxHealthAttribute = Objects.requireNonNull(
|
final AttributeInstance maxHealthAttribute = Objects.requireNonNull(
|
||||||
player.getAttribute(Attribute.GENERIC_MAX_HEALTH), "Missing max health attribute");
|
player.getAttribute(Attribute.GENERIC_MAX_HEALTH), "Max health attribute was null"
|
||||||
|
);
|
||||||
double currentMaxHealth = maxHealthAttribute.getBaseValue();
|
double currentMaxHealth = maxHealthAttribute.getBaseValue();
|
||||||
if (maxHealth != 0d) {
|
if (plugin.getSettings().doSynchronizeMaxHealth() && maxHealth != 0d) {
|
||||||
maxHealthAttribute.setBaseValue(maxHealth);
|
maxHealthAttribute.setBaseValue(maxHealth);
|
||||||
currentMaxHealth = maxHealth;
|
currentMaxHealth = maxHealth;
|
||||||
}
|
}
|
||||||
@@ -853,6 +836,30 @@ public abstract class BukkitData implements Data {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a {@link Player}'s maximum health, minus any health boost effects
|
||||||
|
*
|
||||||
|
* @param player The {@link Player} to get the maximum health of
|
||||||
|
* @return The {@link Player}'s max health
|
||||||
|
*/
|
||||||
|
private static double getMaxHealth(@NotNull Player player) {
|
||||||
|
double maxHealth = Objects.requireNonNull(
|
||||||
|
player.getAttribute(Attribute.GENERIC_MAX_HEALTH), "Max health attribute was null"
|
||||||
|
).getBaseValue();
|
||||||
|
|
||||||
|
// If the player has additional health bonuses from synchronized potion effects,
|
||||||
|
// subtract these from this number as they are synchronized separately
|
||||||
|
if (player.hasPotionEffect(PotionEffectType.HEALTH_BOOST) && maxHealth > 20d) {
|
||||||
|
final PotionEffect healthBoost = Objects.requireNonNull(
|
||||||
|
player.getPotionEffect(PotionEffectType.HEALTH_BOOST), "Health boost effect was null"
|
||||||
|
);
|
||||||
|
final double boostEffect = 4 * (healthBoost.getAmplifier() + 1);
|
||||||
|
maxHealth -= boostEffect;
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxHealth;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public double getHealth() {
|
public double getHealth() {
|
||||||
return health;
|
return health;
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ import org.yaml.snakeyaml.external.biz.base64Coder.Base64Coder;
|
|||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.text.ParseException;
|
import java.text.ParseException;
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
|
||||||
public class BukkitLegacyConverter extends LegacyConverter {
|
public class BukkitLegacyConverter extends LegacyConverter {
|
||||||
|
|
||||||
@@ -49,18 +51,17 @@ public class BukkitLegacyConverter extends LegacyConverter {
|
|||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
@Override
|
@Override
|
||||||
public DataSnapshot.Packed convert(@NotNull byte[] data) throws DataAdapter.AdaptionException {
|
public DataSnapshot.Packed convert(@NotNull byte[] data, @NotNull UUID id,
|
||||||
|
@NotNull OffsetDateTime timestamp) throws DataAdapter.AdaptionException {
|
||||||
final JSONObject object = new JSONObject(plugin.getDataAdapter().bytesToString(data));
|
final JSONObject object = new JSONObject(plugin.getDataAdapter().bytesToString(data));
|
||||||
final int version = object.getInt("format_version");
|
final int version = object.getInt("format_version");
|
||||||
if (version != 3) {
|
if (version != 3) {
|
||||||
throw new DataAdapter.AdaptionException(String.format(
|
plugin.log(Level.WARNING, String.format("Converting data from older v2 data format (%s).", version));
|
||||||
"Unsupported legacy data format version: %s. Please downgrade to an earlier version of HuskSync, " +
|
|
||||||
"perform a manual legacy migration, then attempt to upgrade again.", version
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read legacy data from the JSON object
|
// Read legacy data from the JSON object
|
||||||
final DataSnapshot.Builder builder = DataSnapshot.builder(plugin)
|
final DataSnapshot.Builder builder = DataSnapshot.builder(plugin)
|
||||||
|
.id(id).timestamp(timestamp)
|
||||||
.saveCause(DataSnapshot.SaveCause.CONVERTED_FROM_V2)
|
.saveCause(DataSnapshot.SaveCause.CONVERTED_FROM_V2)
|
||||||
.data(readStatusData(object));
|
.data(readStatusData(object));
|
||||||
readInventory(object).ifPresent(builder::inventory);
|
readInventory(object).ifPresent(builder::inventory);
|
||||||
|
|||||||
@@ -133,12 +133,15 @@ public interface BukkitMapPersister {
|
|||||||
final MapMeta meta = Objects.requireNonNull((MapMeta) map.getItemMeta());
|
final MapMeta meta = Objects.requireNonNull((MapMeta) map.getItemMeta());
|
||||||
NBT.get(map, nbt -> {
|
NBT.get(map, nbt -> {
|
||||||
if (!nbt.hasTag(MAP_DATA_KEY)) {
|
if (!nbt.hasTag(MAP_DATA_KEY)) {
|
||||||
return nbt;
|
return;
|
||||||
}
|
}
|
||||||
final ReadableNBT mapData = nbt.getCompound(MAP_DATA_KEY);
|
final ReadableNBT mapData = nbt.getCompound(MAP_DATA_KEY);
|
||||||
|
final ReadableNBT mapIds = nbt.getCompound(MAP_VIEW_ID_MAPPINGS_KEY);
|
||||||
|
if (mapData == null || mapIds == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Search for an existing map view
|
// Search for an existing map view
|
||||||
final ReadableNBT mapIds = nbt.getCompound(MAP_VIEW_ID_MAPPINGS_KEY);
|
|
||||||
Optional<String> world = Optional.empty();
|
Optional<String> world = Optional.empty();
|
||||||
for (String worldUid : mapIds.getKeys()) {
|
for (String worldUid : mapIds.getKeys()) {
|
||||||
world = Bukkit.getWorlds().stream()
|
world = Bukkit.getWorlds().stream()
|
||||||
@@ -157,7 +160,7 @@ public interface BukkitMapPersister {
|
|||||||
meta.setMapView(view);
|
meta.setMapView(view);
|
||||||
map.setItemMeta(meta);
|
map.setItemMeta(meta);
|
||||||
getPlugin().debug(String.format("View exists (#%s); updated map (UID: %s)", view.getId(), uid));
|
getPlugin().debug(String.format("View exists (#%s); updated map (UID: %s)", view.getId(), uid));
|
||||||
return nbt;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,10 +168,11 @@ public interface BukkitMapPersister {
|
|||||||
final MapData canvasData;
|
final MapData canvasData;
|
||||||
try {
|
try {
|
||||||
getPlugin().debug("Deserializing map data from NBT and generating view...");
|
getPlugin().debug("Deserializing map data from NBT and generating view...");
|
||||||
canvasData = MapData.fromByteArray(mapData.getByteArray(MAP_PIXEL_DATA_KEY));
|
canvasData = MapData.fromByteArray(Objects.requireNonNull(mapData.getByteArray(MAP_PIXEL_DATA_KEY),
|
||||||
|
"Map pixel data is null"));
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
getPlugin().log(Level.WARNING, "Failed to deserialize map data from NBT", e);
|
getPlugin().log(Level.WARNING, "Failed to deserialize map data from NBT", e);
|
||||||
return nbt;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a renderer to the map with the data
|
// Add a renderer to the map with the data
|
||||||
@@ -179,10 +183,11 @@ public interface BukkitMapPersister {
|
|||||||
|
|
||||||
// Set the map view ID in NBT
|
// Set the map view ID in NBT
|
||||||
NBT.modify(map, editable -> {
|
NBT.modify(map, editable -> {
|
||||||
editable.getCompound(MAP_VIEW_ID_MAPPINGS_KEY).setInteger(worldUid, view.getId());
|
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().debug(String.format("Generated view (#%s) and updated map (UID: %s)", view.getId(), worldUid));
|
||||||
return nbt;
|
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ public class UserDataCommand extends Command implements TabProvider {
|
|||||||
case "restore" -> {
|
case "restore" -> {
|
||||||
if (optionalUuid.isEmpty()) {
|
if (optionalUuid.isEmpty()) {
|
||||||
plugin.getLocales().getLocale("error_invalid_syntax",
|
plugin.getLocales().getLocale("error_invalid_syntax",
|
||||||
"/userdata delete <username> <version_uuid>")
|
"/userdata restore <username> <version_uuid>")
|
||||||
.ifPresent(executor::sendMessage);
|
.ifPresent(executor::sendMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,7 +177,11 @@ public class Settings {
|
|||||||
|
|
||||||
@YamlComment("(Experimental) Persist Cartography Table locked maps to let them be viewed on any server")
|
@YamlComment("(Experimental) Persist Cartography Table locked maps to let them be viewed on any server")
|
||||||
@YamlKey("synchronization.persist_locked_maps")
|
@YamlKey("synchronization.persist_locked_maps")
|
||||||
private boolean persistLockedMaps = false;
|
private boolean persistLockedMaps = true;
|
||||||
|
|
||||||
|
@YamlComment("Whether to synchronize player max health (requires health syncing to be enabled)")
|
||||||
|
@YamlKey("synchronization.synchronize_max_health")
|
||||||
|
private boolean synchronizeMaxHealth = true;
|
||||||
|
|
||||||
@YamlComment("Whether dead players who log out and log in to a different server should have their items saved. "
|
@YamlComment("Whether dead players who log out and log in to a different server should have their items saved. "
|
||||||
+ "You may need to modify this if you're using the keepInventory gamerule.")
|
+ "You may need to modify this if you're using the keepInventory gamerule.")
|
||||||
@@ -352,6 +356,10 @@ public class Settings {
|
|||||||
return synchronizeDeadPlayersChangingServer;
|
return synchronizeDeadPlayersChangingServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean doSynchronizeMaxHealth() {
|
||||||
|
return synchronizeMaxHealth;
|
||||||
|
}
|
||||||
|
|
||||||
public int getNetworkLatencyMilliseconds() {
|
public int getNetworkLatencyMilliseconds() {
|
||||||
return networkLatencyMilliseconds;
|
return networkLatencyMilliseconds;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ public class DataSnapshot {
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* Current version of the snapshot data format.
|
* Current version of the snapshot data format.
|
||||||
* HuskSync v3.0 uses v4; HuskSync v2.0 uses v3. HuskSync v1.0 uses v1 or v2
|
* HuskSync v3.0 uses v4; HuskSync v2.0 uses v1-v3
|
||||||
*/
|
*/
|
||||||
protected static final int CURRENT_FORMAT_VERSION = 4;
|
protected static final int CURRENT_FORMAT_VERSION = 4;
|
||||||
|
|
||||||
@@ -96,9 +96,11 @@ public class DataSnapshot {
|
|||||||
return new Builder(plugin);
|
return new Builder(plugin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deserialize a DataSnapshot downloaded from the database (with an ID & Timestamp from the database)
|
||||||
@NotNull
|
@NotNull
|
||||||
@ApiStatus.Internal
|
@ApiStatus.Internal
|
||||||
public static DataSnapshot.Packed deserialize(@NotNull HuskSync plugin, byte[] data) throws IllegalStateException {
|
public static DataSnapshot.Packed deserialize(@NotNull HuskSync plugin, byte[] data, @Nullable UUID id,
|
||||||
|
@Nullable OffsetDateTime timestamp) throws IllegalStateException {
|
||||||
final DataSnapshot.Packed snapshot = plugin.getDataAdapter().fromBytes(data, DataSnapshot.Packed.class);
|
final DataSnapshot.Packed snapshot = plugin.getDataAdapter().fromBytes(data, DataSnapshot.Packed.class);
|
||||||
if (snapshot.getMinecraftVersion().compareTo(plugin.getMinecraftVersion()) > 0) {
|
if (snapshot.getMinecraftVersion().compareTo(plugin.getMinecraftVersion()) > 0) {
|
||||||
throw new IllegalStateException(String.format("Cannot set data for user because the Minecraft version of " +
|
throw new IllegalStateException(String.format("Cannot set data for user because the Minecraft version of " +
|
||||||
@@ -114,7 +116,11 @@ public class DataSnapshot {
|
|||||||
}
|
}
|
||||||
if (snapshot.getFormatVersion() < CURRENT_FORMAT_VERSION) {
|
if (snapshot.getFormatVersion() < CURRENT_FORMAT_VERSION) {
|
||||||
if (plugin.getLegacyConverter().isPresent()) {
|
if (plugin.getLegacyConverter().isPresent()) {
|
||||||
return plugin.getLegacyConverter().get().convert(data);
|
return plugin.getLegacyConverter().get().convert(
|
||||||
|
data,
|
||||||
|
Objects.requireNonNull(id, "Attempted legacy conversion with null UUID!"),
|
||||||
|
Objects.requireNonNull(timestamp, "Attempted legacy conversion with null timestamp!")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw new IllegalStateException(String.format(
|
throw new IllegalStateException(String.format(
|
||||||
"No legacy converter to convert format version: %s", snapshot.getFormatVersion()
|
"No legacy converter to convert format version: %s", snapshot.getFormatVersion()
|
||||||
@@ -129,6 +135,13 @@ public class DataSnapshot {
|
|||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deserialize a DataSnapshot from a network message payload (without an ID)
|
||||||
|
@NotNull
|
||||||
|
@ApiStatus.Internal
|
||||||
|
public static DataSnapshot.Packed deserialize(@NotNull HuskSync plugin, byte[] data) throws IllegalStateException {
|
||||||
|
return deserialize(plugin, data, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the ID of the snapshot
|
* Return the ID of the snapshot
|
||||||
*
|
*
|
||||||
@@ -227,12 +240,6 @@ public class DataSnapshot {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the format version of the snapshot (indicating the version of HuskSync that created it)
|
* Get the format version of the snapshot (indicating the version of HuskSync that created it)
|
||||||
* <ul>
|
|
||||||
* <li>1: HuskSync v1.0+</li>
|
|
||||||
* <li>2: HuskSync v1.5+</li>
|
|
||||||
* <li>3: HuskSync v2.0+</li>
|
|
||||||
* <li>4: HuskSync v3.0+</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
*
|
||||||
* @return The format version of the snapshot
|
* @return The format version of the snapshot
|
||||||
* @since 3.0
|
* @since 3.0
|
||||||
@@ -393,6 +400,7 @@ public class DataSnapshot {
|
|||||||
public static class Builder {
|
public static class Builder {
|
||||||
|
|
||||||
private final HuskSync plugin;
|
private final HuskSync plugin;
|
||||||
|
private UUID id;
|
||||||
private SaveCause saveCause;
|
private SaveCause saveCause;
|
||||||
private boolean pinned;
|
private boolean pinned;
|
||||||
private OffsetDateTime timestamp;
|
private OffsetDateTime timestamp;
|
||||||
@@ -403,6 +411,19 @@ public class DataSnapshot {
|
|||||||
this.pinned = false;
|
this.pinned = false;
|
||||||
this.data = new HashMap<>();
|
this.data = new HashMap<>();
|
||||||
this.timestamp = OffsetDateTime.now();
|
this.timestamp = OffsetDateTime.now();
|
||||||
|
this.id = UUID.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the {@link UUID unique ID} of the snapshot
|
||||||
|
*
|
||||||
|
* @param id The {@link UUID} of the snapshot
|
||||||
|
* @return The builder
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
public Builder id(@NotNull UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -661,7 +682,7 @@ public class DataSnapshot {
|
|||||||
throw new IllegalStateException("Cannot build DataSnapshot without a save cause");
|
throw new IllegalStateException("Cannot build DataSnapshot without a save cause");
|
||||||
}
|
}
|
||||||
return new Unpacked(
|
return new Unpacked(
|
||||||
UUID.randomUUID(),
|
id,
|
||||||
pinned || plugin.getSettings().doAutoPin(saveCause),
|
pinned || plugin.getSettings().doAutoPin(saveCause),
|
||||||
timestamp,
|
timestamp,
|
||||||
saveCause,
|
saveCause,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import org.jetbrains.annotations.NotNull;
|
|||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A holder of data in the form of {@link Data}s, which can be synced
|
* A holder of data in the form of {@link Data}s, which can be synced
|
||||||
@@ -83,22 +84,40 @@ public interface UserDataHolder extends DataHolder {
|
|||||||
* The {@code runAfter} callback function will be run after the snapshot has been applied.
|
* The {@code runAfter} callback function will be run after the snapshot has been applied.
|
||||||
*
|
*
|
||||||
* @param snapshot the snapshot to apply
|
* @param snapshot the snapshot to apply
|
||||||
* @param runAfter the function to run asynchronously after the snapshot has been applied
|
* @param runAfter a consumer accepting a boolean value, indicating if the data was successfully applied,
|
||||||
|
* which will be run after the snapshot has been applied
|
||||||
* @since 3.0
|
* @since 3.0
|
||||||
*/
|
*/
|
||||||
default void applySnapshot(@NotNull DataSnapshot.Packed snapshot, @NotNull ThrowingConsumer<UserDataHolder> runAfter) {
|
default void applySnapshot(@NotNull DataSnapshot.Packed snapshot, @NotNull ThrowingConsumer<Boolean> runAfter) {
|
||||||
final HuskSync plugin = getPlugin();
|
final HuskSync plugin = getPlugin();
|
||||||
final DataSnapshot.Unpacked unpacked = snapshot.unpack(plugin);
|
|
||||||
|
// Unpack the snapshot
|
||||||
|
final DataSnapshot.Unpacked unpacked;
|
||||||
|
try {
|
||||||
|
unpacked = snapshot.unpack(plugin);
|
||||||
|
} catch (Throwable e) {
|
||||||
|
plugin.log(Level.SEVERE, String.format("Failed to unpack data snapshot for %s", getUsername()), e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronously attempt to apply the snapshot
|
||||||
plugin.runSync(() -> {
|
plugin.runSync(() -> {
|
||||||
unpacked.getData().forEach((type, data) -> {
|
try {
|
||||||
if (plugin.getSettings().isSyncFeatureEnabled(type)) {
|
for (Map.Entry<Identifier, Data> entry : unpacked.getData().entrySet()) {
|
||||||
if (type.isCustom()) {
|
final Identifier identifier = entry.getKey();
|
||||||
getCustomDataStore().put(type, data);
|
if (plugin.getSettings().isSyncFeatureEnabled(identifier)) {
|
||||||
|
if (identifier.isCustom()) {
|
||||||
|
getCustomDataStore().put(identifier, entry.getValue());
|
||||||
|
}
|
||||||
|
entry.getValue().apply(this, plugin);
|
||||||
}
|
}
|
||||||
data.apply(this, plugin);
|
|
||||||
}
|
}
|
||||||
});
|
} catch (Throwable e) {
|
||||||
plugin.runAsync(() -> runAfter.accept(this));
|
plugin.log(Level.SEVERE, String.format("Failed to apply data snapshot to %s", getUsername()), e);
|
||||||
|
plugin.runAsync(() -> runAfter.accept(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
plugin.runAsync(() -> runAfter.accept(true));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +176,9 @@ public interface UserDataHolder extends DataHolder {
|
|||||||
this.setData(Identifier.PERSISTENT_DATA, persistentData);
|
this.setData(Identifier.PERSISTENT_DATA, persistentData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
String getUsername();
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
Map<Identifier, Data> getCustomDataStore();
|
Map<Identifier, Data> getCustomDataStore();
|
||||||
|
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ public class MySqlDatabase extends Database {
|
|||||||
public Optional<DataSnapshot.Packed> getLatestSnapshot(@NotNull User user) {
|
public Optional<DataSnapshot.Packed> getLatestSnapshot(@NotNull User user) {
|
||||||
try (Connection connection = getConnection()) {
|
try (Connection connection = getConnection()) {
|
||||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||||
SELECT `version_uuid`, `timestamp`, `save_cause`, `pinned`, `data`
|
SELECT `version_uuid`, `timestamp`, `data`
|
||||||
FROM `%user_data_table%`
|
FROM `%user_data_table%`
|
||||||
WHERE `player_uuid`=?
|
WHERE `player_uuid`=?
|
||||||
ORDER BY `timestamp` DESC
|
ORDER BY `timestamp` DESC
|
||||||
@@ -225,10 +225,14 @@ public class MySqlDatabase extends Database {
|
|||||||
statement.setString(1, user.getUuid().toString());
|
statement.setString(1, user.getUuid().toString());
|
||||||
final ResultSet resultSet = statement.executeQuery();
|
final ResultSet resultSet = statement.executeQuery();
|
||||||
if (resultSet.next()) {
|
if (resultSet.next()) {
|
||||||
|
final UUID versionUuid = UUID.fromString(resultSet.getString("version_uuid"));
|
||||||
|
final OffsetDateTime timestamp = OffsetDateTime.ofInstant(
|
||||||
|
resultSet.getTimestamp("timestamp").toInstant(), TimeZone.getDefault().toZoneId()
|
||||||
|
);
|
||||||
final Blob blob = resultSet.getBlob("data");
|
final Blob blob = resultSet.getBlob("data");
|
||||||
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
|
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
|
||||||
blob.free();
|
blob.free();
|
||||||
return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray));
|
return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray, versionUuid, timestamp));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (SQLException | DataAdapter.AdaptionException e) {
|
} catch (SQLException | DataAdapter.AdaptionException e) {
|
||||||
@@ -244,17 +248,21 @@ public class MySqlDatabase extends Database {
|
|||||||
final List<DataSnapshot.Packed> retrievedData = new ArrayList<>();
|
final List<DataSnapshot.Packed> retrievedData = new ArrayList<>();
|
||||||
try (Connection connection = getConnection()) {
|
try (Connection connection = getConnection()) {
|
||||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||||
SELECT `version_uuid`, `timestamp`, `save_cause`, `pinned`, `data`
|
SELECT `version_uuid`, `timestamp`, `data`
|
||||||
FROM `%user_data_table%`
|
FROM `%user_data_table%`
|
||||||
WHERE `player_uuid`=?
|
WHERE `player_uuid`=?
|
||||||
ORDER BY `timestamp` DESC;"""))) {
|
ORDER BY `timestamp` DESC;"""))) {
|
||||||
statement.setString(1, user.getUuid().toString());
|
statement.setString(1, user.getUuid().toString());
|
||||||
final ResultSet resultSet = statement.executeQuery();
|
final ResultSet resultSet = statement.executeQuery();
|
||||||
while (resultSet.next()) {
|
while (resultSet.next()) {
|
||||||
|
final UUID versionUuid = UUID.fromString(resultSet.getString("version_uuid"));
|
||||||
|
final OffsetDateTime timestamp = OffsetDateTime.ofInstant(
|
||||||
|
resultSet.getTimestamp("timestamp").toInstant(), TimeZone.getDefault().toZoneId()
|
||||||
|
);
|
||||||
final Blob blob = resultSet.getBlob("data");
|
final Blob blob = resultSet.getBlob("data");
|
||||||
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
|
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
|
||||||
blob.free();
|
blob.free();
|
||||||
retrievedData.add(DataSnapshot.deserialize(plugin, dataByteArray));
|
retrievedData.add(DataSnapshot.deserialize(plugin, dataByteArray, versionUuid, timestamp));
|
||||||
}
|
}
|
||||||
return retrievedData;
|
return retrievedData;
|
||||||
}
|
}
|
||||||
@@ -269,7 +277,7 @@ public class MySqlDatabase extends Database {
|
|||||||
public Optional<DataSnapshot.Packed> getSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
|
public Optional<DataSnapshot.Packed> getSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
|
||||||
try (Connection connection = getConnection()) {
|
try (Connection connection = getConnection()) {
|
||||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||||
SELECT `version_uuid`, `timestamp`, `save_cause`, `pinned`, `data`
|
SELECT `version_uuid`, `timestamp`, `data`
|
||||||
FROM `%user_data_table%`
|
FROM `%user_data_table%`
|
||||||
WHERE `player_uuid`=? AND `version_uuid`=?
|
WHERE `player_uuid`=? AND `version_uuid`=?
|
||||||
ORDER BY `timestamp` DESC
|
ORDER BY `timestamp` DESC
|
||||||
@@ -279,9 +287,12 @@ public class MySqlDatabase extends Database {
|
|||||||
final ResultSet resultSet = statement.executeQuery();
|
final ResultSet resultSet = statement.executeQuery();
|
||||||
if (resultSet.next()) {
|
if (resultSet.next()) {
|
||||||
final Blob blob = resultSet.getBlob("data");
|
final Blob blob = resultSet.getBlob("data");
|
||||||
|
final OffsetDateTime timestamp = OffsetDateTime.ofInstant(
|
||||||
|
resultSet.getTimestamp("timestamp").toInstant(), TimeZone.getDefault().toZoneId()
|
||||||
|
);
|
||||||
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
|
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
|
||||||
blob.free();
|
blob.free();
|
||||||
return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray));
|
return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray, versionUuid, timestamp));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (SQLException | DataAdapter.AdaptionException e) {
|
} catch (SQLException | DataAdapter.AdaptionException e) {
|
||||||
|
|||||||
@@ -125,12 +125,14 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo
|
|||||||
* Set a player's status from a {@link DataSnapshot}
|
* Set a player's status from a {@link DataSnapshot}
|
||||||
*
|
*
|
||||||
* @param snapshot The {@link DataSnapshot} to set the player's status from
|
* @param snapshot The {@link DataSnapshot} to set the player's status from
|
||||||
|
* @param cause The {@link DataSnapshot.UpdateCause} of the snapshot
|
||||||
|
* @since 3.0
|
||||||
*/
|
*/
|
||||||
public void applySnapshot(@NotNull DataSnapshot.Packed snapshot, @NotNull DataSnapshot.UpdateCause cause) {
|
public void applySnapshot(@NotNull DataSnapshot.Packed snapshot, @NotNull DataSnapshot.UpdateCause cause) {
|
||||||
getPlugin().fireEvent(getPlugin().getPreSyncEvent(this, snapshot), (event) -> {
|
getPlugin().fireEvent(getPlugin().getPreSyncEvent(this, snapshot), (event) -> {
|
||||||
if (!isOffline()) {
|
if (!isOffline()) {
|
||||||
UserDataHolder.super.applySnapshot(
|
UserDataHolder.super.applySnapshot(
|
||||||
event.getData(), (owner) -> completeSync(true, cause, getPlugin())
|
event.getData(), (succeeded) -> completeSync(succeeded, cause, getPlugin())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import net.william278.husksync.adapter.DataAdapter;
|
|||||||
import net.william278.husksync.data.DataSnapshot;
|
import net.william278.husksync.data.DataSnapshot;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
public abstract class LegacyConverter {
|
public abstract class LegacyConverter {
|
||||||
|
|
||||||
protected final HuskSync plugin;
|
protected final HuskSync plugin;
|
||||||
@@ -33,6 +36,7 @@ public abstract class LegacyConverter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
public abstract DataSnapshot.Packed convert(@NotNull byte[] data) throws DataAdapter.AdaptionException;
|
public abstract DataSnapshot.Packed convert(@NotNull byte[] data, @NotNull UUID id,
|
||||||
|
@NotNull OffsetDateTime timestamp) throws DataAdapter.AdaptionException;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ synchronization:
|
|||||||
# Where to display sync notifications (ACTION_BAR, CHAT, TOAST or NONE)
|
# Where to display sync notifications (ACTION_BAR, CHAT, TOAST or NONE)
|
||||||
notification_display_slot: ACTION_BAR
|
notification_display_slot: ACTION_BAR
|
||||||
# (Experimental) Persist Cartography Table locked maps to let them be viewed on any server
|
# (Experimental) Persist Cartography Table locked maps to let them be viewed on any server
|
||||||
persist_locked_maps: false
|
persist_locked_maps: true
|
||||||
|
# Whether to synchronize player max health (requires health syncing to be enabled)
|
||||||
|
synchronize_max_health: true
|
||||||
# Whether dead players who log out and log in to a different server should have their items saved. You may need to modify this if you're using the keepInventory gamerule.
|
# Whether dead players who log out and log in to a different server should have their items saved. You may need to modify this if you're using the keepInventory gamerule.
|
||||||
synchronize_dead_players_changing_server: true
|
synchronize_dead_players_changing_server: true
|
||||||
# How long, in milliseconds, this server should wait for a response from the redis server before pulling data from the database instead (i.e., if the user did not change servers).
|
# How long, in milliseconds, this server should wait for a response from the redis server before pulling data from the database instead (i.e., if the user did not change servers).
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ org.gradle.jvmargs='-Dfile.encoding=UTF-8'
|
|||||||
org.gradle.daemon=true
|
org.gradle.daemon=true
|
||||||
javaVersion=16
|
javaVersion=16
|
||||||
|
|
||||||
plugin_version=3.0
|
plugin_version=3.0.2
|
||||||
plugin_archive=husksync
|
plugin_archive=husksync
|
||||||
plugin_description=A modern, cross-server player data synchronization system
|
plugin_description=A modern, cross-server player data synchronization system
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user