Introduce new lockstep syncing system, modularize sync modes (#178)

* Start work on modular sync systems

* Add experimental lockstep sync system, close #69

* Refactor RedisMessageType enum

* Fixup lockstep syncing

* Bump to 3.1

* Update docs with details about the new Sync Modes

* Sync mode config key is `mode` instead of `type`

* Add server to data snapshot overview

* API: Add API for setting data syncers

* Fixup weird statistic matching logic
feat/data-edit-commands
William 1 year ago committed by GitHub
parent 03ca335293
commit cae17f6e68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -28,6 +28,7 @@ import net.william278.husksync.adapter.SnappyGsonAdapter;
import net.william278.husksync.api.BukkitHuskSyncAPI; import net.william278.husksync.api.BukkitHuskSyncAPI;
import net.william278.husksync.command.BukkitCommand; import net.william278.husksync.command.BukkitCommand;
import net.william278.husksync.config.Locales; import net.william278.husksync.config.Locales;
import net.william278.husksync.config.Server;
import net.william278.husksync.config.Settings; import net.william278.husksync.config.Settings;
import net.william278.husksync.data.BukkitSerializer; import net.william278.husksync.data.BukkitSerializer;
import net.william278.husksync.data.Data; import net.william278.husksync.data.Data;
@ -43,6 +44,7 @@ import net.william278.husksync.migrator.LegacyMigrator;
import net.william278.husksync.migrator.Migrator; import net.william278.husksync.migrator.Migrator;
import net.william278.husksync.migrator.MpdbMigrator; import net.william278.husksync.migrator.MpdbMigrator;
import net.william278.husksync.redis.RedisManager; import net.william278.husksync.redis.RedisManager;
import net.william278.husksync.sync.DataSyncer;
import net.william278.husksync.user.BukkitUser; import net.william278.husksync.user.BukkitUser;
import net.william278.husksync.user.ConsoleUser; import net.william278.husksync.user.ConsoleUser;
import net.william278.husksync.user.OnlineUser; import net.william278.husksync.user.OnlineUser;
@ -64,6 +66,7 @@ import space.arim.morepaperlib.scheduling.RegionalScheduler;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -82,8 +85,11 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
private DataAdapter dataAdapter; private DataAdapter dataAdapter;
private Map<Identifier, Serializer<? extends Data>> serializers; private Map<Identifier, Serializer<? extends Data>> serializers;
private Map<UUID, Map<Identifier, Data>> playerCustomDataStore; private Map<UUID, Map<Identifier, Data>> playerCustomDataStore;
private Set<UUID> lockedPlayers;
private DataSyncer dataSyncer;
private Settings settings; private Settings settings;
private Locales locales; private Locales locales;
private Server server;
private List<Migrator> availableMigrators; private List<Migrator> availableMigrators;
private LegacyConverter legacyConverter; private LegacyConverter legacyConverter;
private Map<Integer, MapView> mapViews; private Map<Integer, MapView> mapViews;
@ -92,15 +98,18 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
private AsynchronousScheduler asyncScheduler; private AsynchronousScheduler asyncScheduler;
private RegionalScheduler regionalScheduler; private RegionalScheduler regionalScheduler;
private Gson gson; private Gson gson;
private boolean disabling;
@Override @Override
public void onEnable() { public void onEnable() {
// Initial plugin setup // Initial plugin setup
this.disabling = false;
this.gson = createGson(); this.gson = createGson();
this.audiences = BukkitAudiences.create(this); this.audiences = BukkitAudiences.create(this);
this.paperLib = new MorePaperLib(this); this.paperLib = new MorePaperLib(this);
this.availableMigrators = new ArrayList<>(); this.availableMigrators = new ArrayList<>();
this.serializers = new LinkedHashMap<>(); this.serializers = new LinkedHashMap<>();
this.lockedPlayers = new ConcurrentSkipListSet<>();
this.playerCustomDataStore = new ConcurrentHashMap<>(); this.playerCustomDataStore = new ConcurrentHashMap<>();
this.mapViews = new ConcurrentHashMap<>(); this.mapViews = new ConcurrentHashMap<>();
@ -152,6 +161,12 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
this.redisManager.initialize(); this.redisManager.initialize();
}); });
// Prepare data syncer
initialize("data syncer", (plugin) -> {
dataSyncer = getSettings().getSyncMode().create(this);
dataSyncer.initialize();
});
// Register events // Register events
initialize("events", (plugin) -> this.eventListener = new BukkitEventListener(this)); initialize("events", (plugin) -> this.eventListener = new BukkitEventListener(this));
@ -176,9 +191,15 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
@Override @Override
public void onDisable() { public void onDisable() {
// Handle shutdown // Handle shutdown
this.disabling = true;
// Close the event listener / data syncer
if (this.eventListener != null) { if (this.eventListener != null) {
this.eventListener.handlePluginDisable(); this.eventListener.handlePluginDisable();
} }
if (this.dataSyncer != null) {
this.dataSyncer.terminate();
}
// Unregister API and cancel tasks // Unregister API and cancel tasks
BukkitHuskSyncAPI.unregister(); BukkitHuskSyncAPI.unregister();
@ -224,6 +245,18 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
return dataAdapter; return dataAdapter;
} }
@NotNull
@Override
public DataSyncer getDataSyncer() {
return dataSyncer;
}
@Override
public void setDataSyncer(@NotNull DataSyncer dataSyncer) {
log(Level.INFO, String.format("Switching data syncer to %s", dataSyncer.getClass().getSimpleName()));
this.dataSyncer = dataSyncer;
}
@NotNull @NotNull
@Override @Override
public Map<Identifier, Serializer<? extends Data>> getSerializers() { public Map<Identifier, Serializer<? extends Data>> getSerializers() {
@ -258,6 +291,17 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
this.settings = settings; this.settings = settings;
} }
@NotNull
@Override
public String getServerName() {
return server.getName();
}
@Override
public void setServer(@NotNull Server server) {
this.server = server;
}
@Override @Override
@NotNull @NotNull
public Locales getLocales() { public Locales getLocales() {
@ -328,7 +372,7 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
@NotNull @NotNull
@Override @Override
public Set<UUID> getLockedPlayers() { public Set<UUID> getLockedPlayers() {
return this.eventListener.getLockedPlayers(); return lockedPlayers;
} }
@NotNull @NotNull
@ -337,6 +381,11 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
return gson; return gson;
} }
@Override
public boolean isDisabling() {
return disabling;
}
@NotNull @NotNull
public Map<Integer, MapView> getMapViews() { public Map<Integer, MapView> getMapViews() {
return mapViews; return mapViews;

@ -657,20 +657,18 @@ public abstract class BukkitData implements Data {
return new StatisticsMap(genericStats, blockStats, itemStats, entityStats); return new StatisticsMap(genericStats, blockStats, itemStats, entityStats);
} }
@NotNull @Nullable
private static Statistic matchStatistic(@NotNull String key) { private static Statistic matchStatistic(@NotNull String key) {
return Arrays.stream(Statistic.values()) return Arrays.stream(Statistic.values())
.filter(stat -> stat.getKey().toString().equals(key)) .filter(stat -> stat.getKey().toString().equals(key))
.findFirst() .findFirst().orElse(null);
.orElseThrow(() -> new IllegalArgumentException(String.format("Invalid statistic key: %s", key)));
} }
@NotNull @Nullable
private static EntityType matchEntityType(@NotNull String key) { private static EntityType matchEntityType(@NotNull String key) {
return Arrays.stream(EntityType.values()) return Arrays.stream(EntityType.values())
.filter(entityType -> entityType.getKey().toString().equals(key)) .filter(entityType -> entityType.getKey().toString().equals(key))
.findFirst() .findFirst().orElse(null);
.orElseThrow(() -> new IllegalArgumentException(String.format("Invalid entity type key: %s", key)));
} }
@Override @Override

@ -28,6 +28,7 @@ import net.william278.desertwell.util.UpdateChecker;
import net.william278.desertwell.util.Version; import net.william278.desertwell.util.Version;
import net.william278.husksync.adapter.DataAdapter; import net.william278.husksync.adapter.DataAdapter;
import net.william278.husksync.config.Locales; import net.william278.husksync.config.Locales;
import net.william278.husksync.config.Server;
import net.william278.husksync.config.Settings; import net.william278.husksync.config.Settings;
import net.william278.husksync.data.Data; import net.william278.husksync.data.Data;
import net.william278.husksync.data.Identifier; import net.william278.husksync.data.Identifier;
@ -36,6 +37,7 @@ import net.william278.husksync.database.Database;
import net.william278.husksync.event.EventDispatcher; import net.william278.husksync.event.EventDispatcher;
import net.william278.husksync.migrator.Migrator; import net.william278.husksync.migrator.Migrator;
import net.william278.husksync.redis.RedisManager; import net.william278.husksync.redis.RedisManager;
import net.william278.husksync.sync.DataSyncer;
import net.william278.husksync.user.ConsoleUser; import net.william278.husksync.user.ConsoleUser;
import net.william278.husksync.user.OnlineUser; import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.util.LegacyConverter; import net.william278.husksync.util.LegacyConverter;
@ -90,6 +92,11 @@ public interface HuskSync extends Task.Supplier, EventDispatcher {
@NotNull @NotNull
RedisManager getRedisManager(); RedisManager getRedisManager();
/**
* Returns the implementing adapter for serializing data
*
* @return the {@link DataAdapter}
*/
@NotNull @NotNull
DataAdapter getDataAdapter(); DataAdapter getDataAdapter();
@ -130,6 +137,21 @@ public interface HuskSync extends Task.Supplier, EventDispatcher {
return getSerializers().keySet(); return getSerializers().keySet();
} }
/**
* Returns the data syncer implementation
*
* @return the {@link DataSyncer} implementation
*/
@NotNull
DataSyncer getDataSyncer();
/**
* Set the data syncer implementation
*
* @param dataSyncer the {@link DataSyncer} implementation
*/
void setDataSyncer(@NotNull DataSyncer dataSyncer);
/** /**
* Returns a list of available data {@link Migrator}s * Returns a list of available data {@link Migrator}s
* *
@ -167,6 +189,11 @@ public interface HuskSync extends Task.Supplier, EventDispatcher {
void setSettings(@NotNull Settings settings); void setSettings(@NotNull Settings settings);
@NotNull
String getServerName();
void setServer(@NotNull Server server);
/** /**
* Returns the plugin {@link Locales} * Returns the plugin {@link Locales}
* *
@ -255,7 +282,7 @@ public interface HuskSync extends Task.Supplier, EventDispatcher {
String getPlatformType(); String getPlatformType();
/** /**
* Returns the legacy data converter, if it exists * Returns the legacy data converter if it exists
* *
* @return the {@link LegacyConverter} * @return the {@link LegacyConverter}
*/ */
@ -269,6 +296,9 @@ public interface HuskSync extends Task.Supplier, EventDispatcher {
// Load settings // Load settings
setSettings(Annotaml.create(new File(getDataFolder(), "config.yml"), Settings.class).get()); setSettings(Annotaml.create(new File(getDataFolder(), "config.yml"), Settings.class).get());
// Load server name
setServer(Annotaml.create(new File(getDataFolder(), "server.yml"), Server.class).get());
// Load locales from language preset default // Load locales from language preset default
final Locales languagePresets = Annotaml.create( final Locales languagePresets = Annotaml.create(
Locales.class, Locales.class,
@ -305,12 +335,31 @@ public interface HuskSync extends Task.Supplier, EventDispatcher {
} }
} }
/**
* Get the set of UUIDs of "locked players", for which events will be canceled.
* </p>
* Players are locked while their items are being set (on join) or saved (on quit)
*/
@NotNull @NotNull
Set<UUID> getLockedPlayers(); Set<UUID> getLockedPlayers();
default boolean isLocked(@NotNull UUID uuid) {
return getLockedPlayers().contains(uuid);
}
default void lockPlayer(@NotNull UUID uuid) {
getLockedPlayers().add(uuid);
}
default void unlockPlayer(@NotNull UUID uuid) {
getLockedPlayers().remove(uuid);
}
@NotNull @NotNull
Gson getGson(); Gson getGson();
boolean isDisabling();
@NotNull @NotNull
default Gson createGson() { default Gson createGson() {
return Converters.registerOffsetDateTime(new GsonBuilder()).create(); return Converters.registerOffsetDateTime(new GsonBuilder()).create();

@ -26,6 +26,7 @@ import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot; import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.data.Identifier; import net.william278.husksync.data.Identifier;
import net.william278.husksync.data.Serializer; import net.william278.husksync.data.Serializer;
import net.william278.husksync.sync.DataSyncer;
import net.william278.husksync.user.OnlineUser; import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User; import net.william278.husksync.user.User;
import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.ApiStatus;
@ -404,6 +405,7 @@ public abstract class HuskSyncAPI {
* @param <T> The type of the element * @param <T> The type of the element
* @return The deserialized element * @return The deserialized element
* @throws Serializer.DeserializationException If the element could not be deserialized * @throws Serializer.DeserializationException If the element could not be deserialized
* @since 3.0
*/ */
@NotNull @NotNull
public <T extends Adaptable> T deserializeData(@NotNull String serialized, Class<T> type) public <T extends Adaptable> T deserializeData(@NotNull String serialized, Class<T> type)
@ -418,6 +420,7 @@ public abstract class HuskSyncAPI {
* @param <T> The type of the element * @param <T> The type of the element
* @return The serialized JSON string * @return The serialized JSON string
* @throws Serializer.SerializationException If the element could not be serialized * @throws Serializer.SerializationException If the element could not be serialized
* @since 3.0
*/ */
@NotNull @NotNull
public <T extends Adaptable> String serializeData(@NotNull T element) public <T extends Adaptable> String serializeData(@NotNull T element)
@ -425,6 +428,16 @@ public abstract class HuskSyncAPI {
return plugin.getDataAdapter().toJson(element); return plugin.getDataAdapter().toJson(element);
} }
/**
* Set the {@link DataSyncer} to be used to sync data
*
* @param syncer The data syncer to use for synchronizing user data
* @since 3.1
*/
public void setDataSyncer(@NotNull DataSyncer syncer) {
plugin.setDataSyncer(syncer);
}
/** /**
* <b>(Internal use only)</b> - Get the plugin instance * <b>(Internal use only)</b> - Get the plugin instance
* *

@ -0,0 +1,77 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* 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.config;
import net.william278.annotaml.YamlFile;
import net.william278.annotaml.YamlKey;
import org.jetbrains.annotations.NotNull;
import java.nio.file.Path;
/**
* Represents a server on a proxied network.
*/
@YamlFile(header = """
HuskSync Server ID config
Developed by William278
This file should contain the ID of this server as defined in your proxy config.
If you join it using /server alpha, then set it to 'alpha' (case-sensitive)""")
public class Server {
/**
* Default server identifier.
*/
@NotNull
public static String getDefaultServerName() {
try {
final Path serverDirectory = Path.of(System.getProperty("user.dir"));
return serverDirectory.getFileName().toString().trim();
} catch (Exception e) {
return "server";
}
}
@YamlKey("name")
private String serverName = getDefaultServerName();
@SuppressWarnings("unused")
private Server() {
}
@Override
public boolean equals(@NotNull Object other) {
// If the name of this server matches another, the servers are the same.
if (other instanceof Server server) {
return server.getName().equalsIgnoreCase(this.getName());
}
return super.equals(other);
}
/**
* Proxy-defined name of this server.
*/
@NotNull
public String getName() {
return serverName;
}
}

@ -26,6 +26,7 @@ import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.data.Identifier; import net.william278.husksync.data.Identifier;
import net.william278.husksync.database.Database; import net.william278.husksync.database.Database;
import net.william278.husksync.listener.EventListener; import net.william278.husksync.listener.EventListener;
import net.william278.husksync.sync.DataSyncer;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.util.*; import java.util.*;
@ -44,7 +45,7 @@ import java.util.*;
public class Settings { public class Settings {
// Top-level settings // Top-level settings
@YamlComment("Locale of the default language file to use. Docs: https://william278.net/docs/huskhomes/translations") @YamlComment("Locale of the default language file to use. Docs: https://william278.net/docs/husksync/translations")
@YamlKey("language") @YamlKey("language")
private String language = "en-gb"; private String language = "en-gb";
@ -135,6 +136,11 @@ public class Settings {
// Synchronization settings // Synchronization settings
@YamlComment("The mode of data synchronization to use (DELAY or LOCKSTEP). DELAY should be fine for most networks."
+ " Docs: https://william278.net/docs/husksync/sync-modes")
@YamlKey("synchronization.mode")
private DataSyncer.Mode syncMode = DataSyncer.Mode.DELAY;
@YamlComment("The number of data snapshot backups that should be kept at once per user") @YamlComment("The number of data snapshot backups that should be kept at once per user")
@YamlKey("synchronization.max_user_data_snapshots") @YamlKey("synchronization.max_user_data_snapshots")
private int maxUserDataSnapshots = 16; private int maxUserDataSnapshots = 16;
@ -150,7 +156,6 @@ public class Settings {
DataSnapshot.SaveCause.INVENTORY_COMMAND.name(), DataSnapshot.SaveCause.INVENTORY_COMMAND.name(),
DataSnapshot.SaveCause.ENDERCHEST_COMMAND.name(), DataSnapshot.SaveCause.ENDERCHEST_COMMAND.name(),
DataSnapshot.SaveCause.BACKUP_RESTORE.name(), DataSnapshot.SaveCause.BACKUP_RESTORE.name(),
DataSnapshot.SaveCause.CONVERTED_FROM_V2.name(),
DataSnapshot.SaveCause.LEGACY_MIGRATION.name(), DataSnapshot.SaveCause.LEGACY_MIGRATION.name(),
DataSnapshot.SaveCause.MPDB_MIGRATION.name() DataSnapshot.SaveCause.MPDB_MIGRATION.name()
); );
@ -188,7 +193,7 @@ public class Settings {
@YamlKey("synchronization.synchronize_dead_players_changing_server") @YamlKey("synchronization.synchronize_dead_players_changing_server")
private boolean synchronizeDeadPlayersChangingServer = true; private boolean synchronizeDeadPlayersChangingServer = true;
@YamlComment("How long, in milliseconds, this server should wait for a response from the redis server before " @YamlComment("If using the DELAY sync method, how long should this server listen for Redis key data updates before "
+ "pulling data from the database instead (i.e., if the user did not change servers).") + "pulling data from the database instead (i.e., if the user did not change servers).")
@YamlKey("synchronization.network_latency_milliseconds") @YamlKey("synchronization.network_latency_milliseconds")
private int networkLatencyMilliseconds = 500; private int networkLatencyMilliseconds = 500;
@ -315,6 +320,11 @@ public class Settings {
return redisUseSsl; return redisUseSsl;
} }
@NotNull
public DataSyncer.Mode getSyncMode() {
return syncMode;
}
public int getMaxUserDataSnapshots() { public int getMaxUserDataSnapshots() {
return maxUserDataSnapshots; return maxUserDataSnapshots;
} }

@ -45,9 +45,9 @@ 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 v1-v3 * HuskSync v3.1 uses v5, v3.0 uses v4; v2.0 uses v1-v3
*/ */
protected static final int CURRENT_FORMAT_VERSION = 4; protected static final int CURRENT_FORMAT_VERSION = 5;
@SerializedName("id") @SerializedName("id")
protected UUID id; protected UUID id;
@ -61,6 +61,9 @@ public class DataSnapshot {
@SerializedName("save_cause") @SerializedName("save_cause")
protected SaveCause saveCause; protected SaveCause saveCause;
@SerializedName("server_name")
protected String serverName;
@SerializedName("minecraft_version") @SerializedName("minecraft_version")
protected String minecraftVersion; protected String minecraftVersion;
@ -74,12 +77,13 @@ public class DataSnapshot {
protected Map<String, String> data; protected Map<String, String> data;
private DataSnapshot(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp, private DataSnapshot(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp,
@NotNull SaveCause saveCause, @NotNull Map<String, String> data, @NotNull SaveCause saveCause, @NotNull String serverName, @NotNull Map<String, String> data,
@NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) { @NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) {
this.id = id; this.id = id;
this.pinned = pinned; this.pinned = pinned;
this.timestamp = timestamp; this.timestamp = timestamp;
this.saveCause = saveCause; this.saveCause = saveCause;
this.serverName = serverName;
this.data = data; this.data = data;
this.minecraftVersion = minecraftVersion.toStringWithoutMetadata(); this.minecraftVersion = minecraftVersion.toStringWithoutMetadata();
this.platformType = platformType; this.platformType = platformType;
@ -114,7 +118,7 @@ public class DataSnapshot {
"Please ensure each server is running the latest version of HuskSync.", "Please ensure each server is running the latest version of HuskSync.",
snapshot.getFormatVersion(), CURRENT_FORMAT_VERSION)); snapshot.getFormatVersion(), CURRENT_FORMAT_VERSION));
} }
if (snapshot.getFormatVersion() < CURRENT_FORMAT_VERSION) { if (snapshot.getFormatVersion() < 4) {
if (plugin.getLegacyConverter().isPresent()) { if (plugin.getLegacyConverter().isPresent()) {
return plugin.getLegacyConverter().get().convert( return plugin.getLegacyConverter().get().convert(
data, data,
@ -195,13 +199,26 @@ public class DataSnapshot {
return saveCause; return saveCause;
} }
/**
* Get the server the snapshot was created on.
* <p>
* Note that snapshots generated before v3.1 will return {@code "N/A"}
*
* @return The server name
* @since 3.1
*/
@NotNull
public String getServerName() {
return Optional.ofNullable(serverName).orElse("N/A");
}
/** /**
* Set why the snapshot was created * Set why the snapshot was created
* *
* @param saveCause The {@link SaveCause data save cause} of the snapshot * @param saveCause The {@link SaveCause data save cause} of the snapshot
* @since 3.0 * @since 3.0
*/ */
public void setSaveCause(SaveCause saveCause) { public void setSaveCause(@NotNull SaveCause saveCause) {
this.saveCause = saveCause; this.saveCause = saveCause;
} }
@ -256,9 +273,9 @@ public class DataSnapshot {
public static class Packed extends DataSnapshot implements Adaptable { public static class Packed extends DataSnapshot implements Adaptable {
protected Packed(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp, protected Packed(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp,
@NotNull SaveCause saveCause, @NotNull Map<String, String> data, @NotNull SaveCause saveCause, @NotNull String serverName, @NotNull Map<String, String> data,
@NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) { @NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) {
super(id, pinned, timestamp, saveCause, data, minecraftVersion, platformType, formatVersion); super(id, pinned, timestamp, saveCause, serverName, data, minecraftVersion, platformType, formatVersion);
} }
@SuppressWarnings("unused") @SuppressWarnings("unused")
@ -282,8 +299,8 @@ public class DataSnapshot {
@NotNull @NotNull
public Packed copy() { public Packed copy() {
return new Packed( return new Packed(
UUID.randomUUID(), pinned, OffsetDateTime.now(), saveCause, data, UUID.randomUUID(), pinned, OffsetDateTime.now(), saveCause, serverName,
getMinecraftVersion(), platformType, formatVersion data, getMinecraftVersion(), platformType, formatVersion
); );
} }
@ -307,7 +324,7 @@ public class DataSnapshot {
@NotNull @NotNull
public DataSnapshot.Unpacked unpack(@NotNull HuskSync plugin) { public DataSnapshot.Unpacked unpack(@NotNull HuskSync plugin) {
return new Unpacked( return new Unpacked(
id, pinned, timestamp, saveCause, data, id, pinned, timestamp, saveCause, serverName, data,
getMinecraftVersion(), platformType, formatVersion, plugin getMinecraftVersion(), platformType, formatVersion, plugin
); );
} }
@ -325,17 +342,17 @@ public class DataSnapshot {
private final Map<Identifier, Data> deserialized; private final Map<Identifier, Data> deserialized;
private Unpacked(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp, private Unpacked(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp,
@NotNull SaveCause saveCause, @NotNull Map<String, String> data, @NotNull SaveCause saveCause, @NotNull String serverName, @NotNull Map<String, String> data,
@NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion, @NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion,
@NotNull HuskSync plugin) { @NotNull HuskSync plugin) {
super(id, pinned, timestamp, saveCause, data, minecraftVersion, platformType, formatVersion); super(id, pinned, timestamp, saveCause, serverName, data, minecraftVersion, platformType, formatVersion);
this.deserialized = deserializeData(plugin); this.deserialized = deserializeData(plugin);
} }
private Unpacked(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp, private Unpacked(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp,
@NotNull SaveCause saveCause, @NotNull Map<Identifier, Data> data, @NotNull SaveCause saveCause, @NotNull String serverName, @NotNull Map<Identifier, Data> data,
@NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) { @NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) {
super(id, pinned, timestamp, saveCause, Map.of(), minecraftVersion, platformType, formatVersion); super(id, pinned, timestamp, saveCause, serverName, Map.of(), minecraftVersion, platformType, formatVersion);
this.deserialized = data; this.deserialized = data;
} }
@ -384,7 +401,7 @@ public class DataSnapshot {
@ApiStatus.Internal @ApiStatus.Internal
public DataSnapshot.Packed pack(@NotNull HuskSync plugin) { public DataSnapshot.Packed pack(@NotNull HuskSync plugin) {
return new DataSnapshot.Packed( return new DataSnapshot.Packed(
id, pinned, timestamp, saveCause, serializeData(plugin), id, pinned, timestamp, saveCause, serverName, serializeData(plugin),
getMinecraftVersion(), platformType, formatVersion getMinecraftVersion(), platformType, formatVersion
); );
} }
@ -402,6 +419,7 @@ public class DataSnapshot {
private final HuskSync plugin; private final HuskSync plugin;
private UUID id; private UUID id;
private SaveCause saveCause; private SaveCause saveCause;
private String serverName;
private boolean pinned; private boolean pinned;
private OffsetDateTime timestamp; private OffsetDateTime timestamp;
private final Map<Identifier, Data> data; private final Map<Identifier, Data> data;
@ -412,6 +430,7 @@ public class DataSnapshot {
this.data = new HashMap<>(); this.data = new HashMap<>();
this.timestamp = OffsetDateTime.now(); this.timestamp = OffsetDateTime.now();
this.id = UUID.randomUUID(); this.id = UUID.randomUUID();
this.serverName = plugin.getServerName();
} }
/** /**
@ -441,6 +460,19 @@ public class DataSnapshot {
return this; return this;
} }
/**
* Set the name of the server where this snapshot was created
*
* @param serverName The server name
* @return The builder
* @since 3.1
*/
@NotNull
public Builder serverName(@NotNull String serverName) {
this.serverName = serverName;
return this;
}
/** /**
* Set whether the data should be pinned * Set whether the data should be pinned
* *
@ -686,6 +718,7 @@ public class DataSnapshot {
pinned || plugin.getSettings().doAutoPin(saveCause), pinned || plugin.getSettings().doAutoPin(saveCause),
timestamp, timestamp,
saveCause, saveCause,
serverName,
data, data,
plugin.getMinecraftVersion(), plugin.getMinecraftVersion(),
plugin.getPlatformType(), plugin.getPlatformType(),

@ -23,13 +23,12 @@ import net.william278.husksync.HuskSync;
import net.william278.husksync.data.Data; import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot; import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.OnlineUser; import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.util.Task;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.util.*; import java.util.Arrays;
import java.util.concurrent.atomic.AtomicLong; import java.util.List;
import java.util.concurrent.atomic.AtomicReference; import java.util.Map;
import java.util.logging.Level; import java.util.UUID;
/** /**
* Handles what should happen when events are fired * Handles what should happen when events are fired
@ -39,22 +38,8 @@ public abstract class EventListener {
// The plugin instance // The plugin instance
protected final HuskSync plugin; protected final HuskSync plugin;
/**
* Set of UUIDs of "locked players", for which events will be canceled.
* </p>
* Players are locked while their items are being set (on join) or saved (on quit)
*/
private final Set<UUID> lockedPlayers;
/**
* Whether the plugin is currently being disabled
*/
private boolean disabling;
protected EventListener(@NotNull HuskSync plugin) { protected EventListener(@NotNull HuskSync plugin) {
this.plugin = plugin; this.plugin = plugin;
this.lockedPlayers = new HashSet<>();
this.disabling = false;
} }
/** /**
@ -66,51 +51,8 @@ public abstract class EventListener {
if (user.isNpc()) { if (user.isNpc()) {
return; return;
} }
lockedPlayers.add(user.getUuid()); plugin.lockPlayer(user.getUuid());
plugin.getDataSyncer().setUserData(user);
plugin.runAsyncDelayed(() -> {
// Fetch from the database if the user isn't changing servers
if (!plugin.getRedisManager().getUserServerSwitch(user)) {
this.setUserFromDatabase(user);
return;
}
// Set the user as soon as the source server has set the data to redis
final long MAX_ATTEMPTS = 16L;
final AtomicLong timesRun = new AtomicLong(0L);
final AtomicReference<Task.Repeating> task = new AtomicReference<>();
final Runnable runnable = () -> {
if (user.isOffline()) {
task.get().cancel();
return;
}
if (disabling || timesRun.getAndIncrement() > MAX_ATTEMPTS) {
task.get().cancel();
this.setUserFromDatabase(user);
return;
}
plugin.getRedisManager().getUserData(user).ifPresent(redisData -> {
task.get().cancel();
user.applySnapshot(redisData, DataSnapshot.UpdateCause.SYNCHRONIZED);
});
};
task.set(plugin.getRepeatingTask(runnable, 10));
task.get().run();
}, Math.max(0, plugin.getSettings().getNetworkLatencyMilliseconds() / 50L));
}
/**
* Set a user's data from the database
*
* @param user The user to set the data for
*/
private void setUserFromDatabase(@NotNull OnlineUser user) {
plugin.getDatabase().getLatestSnapshot(user).ifPresentOrElse(
snapshot -> user.applySnapshot(snapshot, DataSnapshot.UpdateCause.SYNCHRONIZED),
() -> user.completeSync(true, DataSnapshot.UpdateCause.NEW_USER, plugin)
);
} }
/** /**
@ -119,27 +61,11 @@ public abstract class EventListener {
* @param user The {@link OnlineUser} to handle * @param user The {@link OnlineUser} to handle
*/ */
protected final void handlePlayerQuit(@NotNull OnlineUser user) { protected final void handlePlayerQuit(@NotNull OnlineUser user) {
// Players quitting have their data manually saved when the plugin is disabled if (user.isNpc() || plugin.isDisabling() || plugin.isLocked(user.getUuid())) {
if (disabling) {
return; return;
} }
plugin.lockPlayer(user.getUuid());
// Don't sync players awaiting synchronization plugin.runAsync(() -> plugin.getDataSyncer().saveUserData(user));
if (lockedPlayers.contains(user.getUuid()) || user.isNpc()) {
return;
}
// Handle disconnection
try {
lockedPlayers.add(user.getUuid());
plugin.getRedisManager().setUserServerSwitch(user).thenRun(() -> {
final DataSnapshot.Packed data = user.createSnapshot(DataSnapshot.SaveCause.DISCONNECT);
plugin.getRedisManager().setUserData(user, data);
plugin.getDatabase().addSnapshot(user, data);
});
} catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred handling a player disconnection", e);
}
} }
/** /**
@ -148,11 +74,11 @@ public abstract class EventListener {
* @param usersInWorld a list of users in the world that is being saved * @param usersInWorld a list of users in the world that is being saved
*/ */
protected final void saveOnWorldSave(@NotNull List<OnlineUser> usersInWorld) { protected final void saveOnWorldSave(@NotNull List<OnlineUser> usersInWorld) {
if (disabling || !plugin.getSettings().doSaveOnWorldSave()) { if (plugin.isDisabling() || !plugin.getSettings().doSaveOnWorldSave()) {
return; return;
} }
usersInWorld.stream() usersInWorld.stream()
.filter(user -> !lockedPlayers.contains(user.getUuid()) && !user.isNpc()) .filter(user -> !plugin.isLocked(user.getUuid()) && !user.isNpc())
.forEach(user -> plugin.getDatabase().addSnapshot( .forEach(user -> plugin.getDatabase().addSnapshot(
user, user.createSnapshot(DataSnapshot.SaveCause.WORLD_SAVE) user, user.createSnapshot(DataSnapshot.SaveCause.WORLD_SAVE)
)); ));
@ -165,8 +91,8 @@ public abstract class EventListener {
* @param drops The items that this user would have dropped * @param drops The items that this user would have dropped
*/ */
protected void saveOnPlayerDeath(@NotNull OnlineUser user, @NotNull Data.Items drops) { protected void saveOnPlayerDeath(@NotNull OnlineUser user, @NotNull Data.Items drops) {
if (disabling || !plugin.getSettings().doSaveOnDeath() || lockedPlayers.contains(user.getUuid()) || user.isNpc() if (plugin.isDisabling() || !plugin.getSettings().doSaveOnDeath() || plugin.isLocked(user.getUuid())
|| (!plugin.getSettings().doSaveEmptyDropsOnDeath() && drops.isEmpty())) { || user.isNpc() || (!plugin.getSettings().doSaveEmptyDropsOnDeath() && drops.isEmpty())) {
return; return;
} }
@ -182,20 +108,18 @@ public abstract class EventListener {
* @return Whether the event should be canceled * @return Whether the event should be canceled
*/ */
protected final boolean cancelPlayerEvent(@NotNull UUID userUuid) { protected final boolean cancelPlayerEvent(@NotNull UUID userUuid) {
return disabling || lockedPlayers.contains(userUuid); return plugin.isDisabling() || plugin.isLocked(userUuid);
} }
/** /**
* Handle the plugin disabling * Handle the plugin disabling
*/ */
public final void handlePluginDisable() { public final void handlePluginDisable() {
disabling = true; // Save for all online players
// Save data for all online users
plugin.getOnlineUsers().stream() plugin.getOnlineUsers().stream()
.filter(user -> !lockedPlayers.contains(user.getUuid()) && !user.isNpc()) .filter(user -> !plugin.isLocked(user.getUuid()) && !user.isNpc())
.forEach(user -> { .forEach(user -> {
lockedPlayers.add(user.getUuid()); plugin.lockPlayer(user.getUuid());
plugin.getDatabase().addSnapshot(user, user.createSnapshot(DataSnapshot.SaveCause.SERVER_SHUTDOWN)); plugin.getDatabase().addSnapshot(user, user.createSnapshot(DataSnapshot.SaveCause.SERVER_SHUTDOWN));
}); });
@ -204,10 +128,6 @@ public abstract class EventListener {
plugin.getRedisManager().terminate(); plugin.getRedisManager().terminate();
} }
public final Set<UUID> getLockedPlayers() {
return this.lockedPlayers;
}
/** /**
* Represents priorities for events that HuskSync listens to * Represents priorities for events that HuskSync listens to
*/ */

@ -24,9 +24,9 @@ import org.jetbrains.annotations.NotNull;
import java.util.Locale; import java.util.Locale;
public enum RedisKeyType { public enum RedisKeyType {
CACHE(60 * 60 * 24),
DATA_UPDATE(10), DATA_UPDATE(10),
SERVER_SWITCH(10); SERVER_SWITCH(10),
DATA_CHECKOUT(60 * 60 * 24 * 7 * 52);
private final int timeToLive; private final int timeToLive;

@ -92,7 +92,7 @@ public class RedisManager extends JedisPubSub {
try (Jedis jedis = jedisPool.getResource()) { try (Jedis jedis = jedisPool.getResource()) {
jedis.subscribe( jedis.subscribe(
this, this,
Arrays.stream(RedisMessageType.values()) Arrays.stream(RedisMessage.Type.values())
.map(type -> type.getMessageChannel(clusterId)) .map(type -> type.getMessageChannel(clusterId))
.toArray(String[]::new) .toArray(String[]::new)
); );
@ -101,7 +101,7 @@ public class RedisManager extends JedisPubSub {
@Override @Override
public void onMessage(@NotNull String channel, @NotNull String message) { public void onMessage(@NotNull String channel, @NotNull String message) {
final RedisMessageType messageType = RedisMessageType.getTypeFromChannel(channel, clusterId).orElse(null); final RedisMessage.Type messageType = RedisMessage.Type.getTypeFromChannel(channel, clusterId).orElse(null);
if (messageType == null) { if (messageType == null) {
return; return;
} }
@ -118,7 +118,7 @@ public class RedisManager extends JedisPubSub {
user -> RedisMessage.create( user -> RedisMessage.create(
UUID.fromString(new String(redisMessage.getPayload(), StandardCharsets.UTF_8)), UUID.fromString(new String(redisMessage.getPayload(), StandardCharsets.UTF_8)),
user.createSnapshot(DataSnapshot.SaveCause.INVENTORY_COMMAND).asBytes(plugin) user.createSnapshot(DataSnapshot.SaveCause.INVENTORY_COMMAND).asBytes(plugin)
).dispatch(plugin, RedisMessageType.RETURN_USER_DATA) ).dispatch(plugin, RedisMessage.Type.RETURN_USER_DATA)
); );
case RETURN_USER_DATA -> { case RETURN_USER_DATA -> {
final CompletableFuture<Optional<DataSnapshot.Packed>> future = pendingRequests.get( final CompletableFuture<Optional<DataSnapshot.Packed>> future = pendingRequests.get(
@ -142,7 +142,7 @@ public class RedisManager extends JedisPubSub {
public void sendUserDataUpdate(@NotNull User user, @NotNull DataSnapshot.Packed data) { public void sendUserDataUpdate(@NotNull User user, @NotNull DataSnapshot.Packed data) {
plugin.runAsync(() -> { plugin.runAsync(() -> {
final RedisMessage redisMessage = RedisMessage.create(user.getUuid(), data.asBytes(plugin)); final RedisMessage redisMessage = RedisMessage.create(user.getUuid(), data.asBytes(plugin));
redisMessage.dispatch(plugin, RedisMessageType.UPDATE_USER_DATA); redisMessage.dispatch(plugin, RedisMessage.Type.UPDATE_USER_DATA);
}); });
} }
@ -162,7 +162,7 @@ public class RedisManager extends JedisPubSub {
user.getUuid(), user.getUuid(),
requestId.toString().getBytes(StandardCharsets.UTF_8) requestId.toString().getBytes(StandardCharsets.UTF_8)
); );
redisMessage.dispatch(plugin, RedisMessageType.REQUEST_USER_DATA); redisMessage.dispatch(plugin, RedisMessage.Type.REQUEST_USER_DATA);
}); });
return future.orTimeout( return future.orTimeout(
plugin.getSettings().getNetworkLatencyMilliseconds(), plugin.getSettings().getNetworkLatencyMilliseconds(),
@ -180,8 +180,8 @@ public class RedisManager extends JedisPubSub {
* @param user the user to set data for * @param user the user to set data for
* @param data the user's data to set * @param data the user's data to set
*/ */
@Blocking
public void setUserData(@NotNull User user, @NotNull DataSnapshot.Packed data) { public void setUserData(@NotNull User user, @NotNull DataSnapshot.Packed data) {
plugin.runAsync(() -> {
try (Jedis jedis = jedisPool.getResource()) { try (Jedis jedis = jedisPool.getResource()) {
jedis.setex( jedis.setex(
getKey(RedisKeyType.DATA_UPDATE, user.getUuid(), clusterId), getKey(RedisKeyType.DATA_UPDATE, user.getUuid(), clusterId),
@ -193,31 +193,83 @@ public class RedisManager extends JedisPubSub {
} catch (Throwable e) { } catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred setting a user's server switch", e); plugin.log(Level.SEVERE, "An exception occurred setting a user's server switch", e);
} }
}); }
@Blocking
public void setUserCheckedOut(@NotNull User user, boolean checkedOut) {
try (Jedis jedis = jedisPool.getResource()) {
if (checkedOut) {
jedis.set(
getKey(RedisKeyType.DATA_CHECKOUT, user.getUuid(), clusterId),
plugin.getServerName().getBytes(StandardCharsets.UTF_8)
);
} else {
jedis.del(getKey(RedisKeyType.DATA_CHECKOUT, user.getUuid(), clusterId));
}
plugin.debug(String.format("[%s] %s %s key to redis at: %s",
checkedOut ? "set" : "removed", user.getUsername(), RedisKeyType.DATA_CHECKOUT.name(),
new SimpleDateFormat("mm:ss.SSS").format(new Date())));
} catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred setting a user's server switch", e);
}
}
@Blocking
public Optional<String> getUserCheckedOut(@NotNull User user) {
try (Jedis jedis = jedisPool.getResource()) {
final byte[] key = getKey(RedisKeyType.DATA_CHECKOUT, user.getUuid(), clusterId);
final byte[] readData = jedis.get(key);
if (readData != null) {
plugin.debug("[" + user.getUsername() + "] Successfully read "
+ RedisKeyType.DATA_CHECKOUT.name() + " key from redis at: " +
new SimpleDateFormat("mm:ss.SSS").format(new Date()));
return Optional.of(new String(readData, StandardCharsets.UTF_8));
}
} catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred fetching a user's checkout key from redis", e);
}
plugin.debug("[" + user.getUsername() + "] Could not read " +
RedisKeyType.DATA_CHECKOUT.name() + " key from redis at: " +
new SimpleDateFormat("mm:ss.SSS").format(new Date()));
return Optional.empty();
}
@Blocking
public void clearUsersCheckedOutOnServer() {
final String keyFormat = String.format("%s*", RedisKeyType.DATA_CHECKOUT.getKeyPrefix(clusterId));
try (Jedis jedis = jedisPool.getResource()) {
final Set<String> keys = jedis.keys(keyFormat);
if (keys == null) {
plugin.log(Level.WARNING, "Checkout key set returned null from jedis during clearing");
return;
}
for (String key : keys) {
if (jedis.get(key).equals(plugin.getServerName())) {
jedis.del(key);
}
}
} catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred clearing users checked out on this server", e);
}
} }
/** /**
* Set a user's server switch to the Redis server * Set a user's server switch to the Redis server
* *
* @param user the user to set the server switch for * @param user the user to set the server switch for
* @return a future returning void when complete
*/ */
public CompletableFuture<Void> setUserServerSwitch(@NotNull User user) { @Blocking
final CompletableFuture<Void> future = new CompletableFuture<>(); public void setUserServerSwitch(@NotNull User user) {
plugin.runAsync(() -> {
try (Jedis jedis = jedisPool.getResource()) { try (Jedis jedis = jedisPool.getResource()) {
jedis.setex( jedis.setex(
getKey(RedisKeyType.SERVER_SWITCH, user.getUuid(), clusterId), getKey(RedisKeyType.SERVER_SWITCH, user.getUuid(), clusterId),
RedisKeyType.SERVER_SWITCH.getTimeToLive(), new byte[0] RedisKeyType.SERVER_SWITCH.getTimeToLive(), new byte[0]
); );
future.complete(null);
plugin.debug(String.format("[%s] Set %s key to redis at: %s", user.getUsername(), plugin.debug(String.format("[%s] Set %s key to redis at: %s", user.getUsername(),
RedisKeyType.SERVER_SWITCH.name(), new SimpleDateFormat("mm:ss.SSS").format(new Date()))); RedisKeyType.SERVER_SWITCH.name(), new SimpleDateFormat("mm:ss.SSS").format(new Date())));
} catch (Throwable e) { } catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred setting a user's server switch", e); plugin.log(Level.SEVERE, "An exception occurred setting a user's server switch", e);
} }
});
return future;
} }
/** /**
@ -226,6 +278,7 @@ public class RedisManager extends JedisPubSub {
* @param user The user to fetch data for * @param user The user to fetch data for
* @return The user's data, if it's present on the database. Otherwise, an empty optional. * @return The user's data, if it's present on the database. Otherwise, an empty optional.
*/ */
@Blocking
public Optional<DataSnapshot.Packed> getUserData(@NotNull User user) { public Optional<DataSnapshot.Packed> getUserData(@NotNull User user) {
try (Jedis jedis = jedisPool.getResource()) { try (Jedis jedis = jedisPool.getResource()) {
final byte[] key = getKey(RedisKeyType.DATA_UPDATE, user.getUuid(), clusterId); final byte[] key = getKey(RedisKeyType.DATA_UPDATE, user.getUuid(), clusterId);
@ -251,6 +304,7 @@ public class RedisManager extends JedisPubSub {
} }
} }
@Blocking
public boolean getUserServerSwitch(@NotNull User user) { public boolean getUserServerSwitch(@NotNull User user) {
try (Jedis jedis = jedisPool.getResource()) { try (Jedis jedis = jedisPool.getResource()) {
final byte[] key = getKey(RedisKeyType.SERVER_SWITCH, user.getUuid(), clusterId); final byte[] key = getKey(RedisKeyType.SERVER_SWITCH, user.getUuid(), clusterId);
@ -274,6 +328,7 @@ public class RedisManager extends JedisPubSub {
} }
} }
@Blocking
public void terminate() { public void terminate() {
if (jedisPool != null) { if (jedisPool != null) {
if (!jedisPool.isClosed()) { if (!jedisPool.isClosed()) {

@ -25,6 +25,9 @@ import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.Adaptable; import net.william278.husksync.adapter.Adaptable;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public class RedisMessage implements Adaptable { public class RedisMessage implements Adaptable {
@ -53,7 +56,7 @@ public class RedisMessage implements Adaptable {
return plugin.getGson().fromJson(json, RedisMessage.class); return plugin.getGson().fromJson(json, RedisMessage.class);
} }
public void dispatch(@NotNull HuskSync plugin, @NotNull RedisMessageType type) { public void dispatch(@NotNull HuskSync plugin, @NotNull Type type) {
plugin.runAsync(() -> plugin.getRedisManager().sendMessage( plugin.runAsync(() -> plugin.getRedisManager().sendMessage(
type.getMessageChannel(plugin.getSettings().getClusterId()), type.getMessageChannel(plugin.getSettings().getClusterId()),
plugin.getGson().toJson(this) plugin.getGson().toJson(this)
@ -77,4 +80,27 @@ public class RedisMessage implements Adaptable {
this.payload = payload; this.payload = payload;
} }
public enum Type {
UPDATE_USER_DATA,
REQUEST_USER_DATA,
RETURN_USER_DATA;
@NotNull
public String getMessageChannel(@NotNull String clusterId) {
return String.format(
"%s:%s:%s",
RedisManager.KEY_NAMESPACE.toLowerCase(Locale.ENGLISH),
clusterId.toLowerCase(Locale.ENGLISH),
name().toLowerCase(Locale.ENGLISH)
);
}
public static Optional<Type> getTypeFromChannel(@NotNull String channel, @NotNull String clusterId) {
return Arrays.stream(values())
.filter(messageType -> messageType.getMessageChannel(clusterId).equalsIgnoreCase(channel))
.findFirst();
}
}
} }

@ -1,50 +0,0 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* 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.redis;
import org.jetbrains.annotations.NotNull;
import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
public enum RedisMessageType {
UPDATE_USER_DATA,
REQUEST_USER_DATA,
RETURN_USER_DATA;
@NotNull
public String getMessageChannel(@NotNull String clusterId) {
return String.format(
"%s:%s:%s",
RedisManager.KEY_NAMESPACE.toLowerCase(Locale.ENGLISH),
clusterId.toLowerCase(Locale.ENGLISH),
name().toLowerCase(Locale.ENGLISH)
);
}
public static Optional<RedisMessageType> getTypeFromChannel(@NotNull String channel, @NotNull String clusterId) {
return Arrays.stream(values())
.filter(messageType -> messageType.getMessageChannel(clusterId).equalsIgnoreCase(channel))
.findFirst();
}
}

@ -0,0 +1,152 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* 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.sync;
import net.william278.husksync.HuskSync;
import net.william278.husksync.api.HuskSyncAPI;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.util.Task;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Handles the synchronization of data when a player changes servers or logs in
*
* @since 3.1
*/
public abstract class DataSyncer {
private static final long BASE_LISTEN_ATTEMPTS = 16;
private static final long LISTEN_DELAY = 10;
protected final HuskSync plugin;
private final long maxListenAttempts;
@ApiStatus.Internal
protected DataSyncer(@NotNull HuskSync plugin) {
this.plugin = plugin;
this.maxListenAttempts = getMaxListenAttempts();
}
/**
* API-exposed constructor for a {@link DataSyncer}
*
* @param api instance of the {@link HuskSyncAPI}
*/
@SuppressWarnings("unused")
public DataSyncer(@NotNull HuskSyncAPI api) {
this(api.getPlugin());
}
/**
* Called when the plugin is enabled
*/
public void initialize() {
}
/**
* Called when the plugin is disabled
*/
public void terminate() {
}
/**
* Called when a user's data should be fetched and applied to them
*
* @param user the user to fetch data for
*/
public abstract void setUserData(@NotNull OnlineUser user);
/**
* Called when a user's data should be serialized and saved
*
* @param user the user to save
*/
public abstract void saveUserData(@NotNull OnlineUser user);
// Calculates the max attempts the system should listen for user data for based on the latency value
private long getMaxListenAttempts() {
return BASE_LISTEN_ATTEMPTS + (
(Math.max(100, plugin.getSettings().getNetworkLatencyMilliseconds()) / 1000) * 20 / LISTEN_DELAY
);
}
// Set a user's data from the database, or set them as a new user
@ApiStatus.Internal
protected void setUserFromDatabase(@NotNull OnlineUser user) {
plugin.getDatabase().getLatestSnapshot(user).ifPresentOrElse(
snapshot -> user.applySnapshot(snapshot, DataSnapshot.UpdateCause.SYNCHRONIZED),
() -> user.completeSync(true, DataSnapshot.UpdateCause.NEW_USER, plugin)
);
}
// Continuously listen for data from Redis
@ApiStatus.Internal
protected void listenForRedisData(@NotNull OnlineUser user, @NotNull Supplier<Boolean> completionSupplier) {
final AtomicLong timesRun = new AtomicLong(0L);
final AtomicReference<Task.Repeating> task = new AtomicReference<>();
final Runnable runnable = () -> {
if (user.isOffline()) {
task.get().cancel();
return;
}
if (plugin.isDisabling() || timesRun.getAndIncrement() > maxListenAttempts) {
task.get().cancel();
setUserFromDatabase(user);
return;
}
if (completionSupplier.get()) {
task.get().cancel();
}
};
task.set(plugin.getRepeatingTask(runnable, LISTEN_DELAY));
task.get().run();
}
/**
* Represents the different available default modes of {@link DataSyncer}
*
* @since 3.1
*/
public enum Mode {
DELAY(DelayDataSyncer::new),
LOCKSTEP(LockstepDataSyncer::new);
private final Function<HuskSync, ? extends DataSyncer> supplier;
Mode(@NotNull Function<HuskSync, ? extends DataSyncer> supplier) {
this.supplier = supplier;
}
@NotNull
public DataSyncer create(@NotNull HuskSync plugin) {
return supplier.apply(plugin);
}
}
}

@ -0,0 +1,69 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* 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.sync;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.OnlineUser;
import org.jetbrains.annotations.NotNull;
/**
* A data syncer which applies a network delay before checking the presence of user data
*/
public class DelayDataSyncer extends DataSyncer {
public DelayDataSyncer(@NotNull HuskSync plugin) {
super(plugin);
}
@Override
public void setUserData(@NotNull OnlineUser user) {
plugin.runAsyncDelayed(
() -> {
// Fetch from the database if the user isn't changing servers
if (!plugin.getRedisManager().getUserServerSwitch(user)) {
this.setUserFromDatabase(user);
return;
}
// Listen for the data to be updated
this.listenForRedisData(
user,
() -> plugin.getRedisManager().getUserData(user).map(data -> {
user.applySnapshot(data, DataSnapshot.UpdateCause.SYNCHRONIZED);
return true;
}).orElse(false)
);
},
Math.max(0, plugin.getSettings().getNetworkLatencyMilliseconds() / 50L)
);
}
@Override
public void saveUserData(@NotNull OnlineUser user) {
plugin.runAsync(() -> {
plugin.getRedisManager().setUserServerSwitch(user);
final DataSnapshot.Packed data = user.createSnapshot(DataSnapshot.SaveCause.DISCONNECT);
plugin.getRedisManager().setUserData(user, data);
plugin.getDatabase().addSnapshot(user, data);
});
}
}

@ -0,0 +1,69 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* 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.sync;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.OnlineUser;
import org.jetbrains.annotations.NotNull;
public class LockstepDataSyncer extends DataSyncer {
public LockstepDataSyncer(@NotNull HuskSync plugin) {
super(plugin);
}
@Override
public void initialize() {
plugin.getRedisManager().clearUsersCheckedOutOnServer();
}
@Override
public void terminate() {
plugin.getRedisManager().clearUsersCheckedOutOnServer();
}
// Consume their data when they are checked in
@Override
public void setUserData(@NotNull OnlineUser user) {
this.listenForRedisData(user, () -> {
if (plugin.getRedisManager().getUserCheckedOut(user).isEmpty()) {
plugin.getRedisManager().setUserCheckedOut(user, true);
plugin.getRedisManager().getUserData(user).ifPresentOrElse(
data -> user.applySnapshot(data, DataSnapshot.UpdateCause.SYNCHRONIZED),
() -> this.setUserFromDatabase(user)
);
return true;
}
return false;
});
}
@Override
public void saveUserData(@NotNull OnlineUser user) {
plugin.runAsync(() -> {
final DataSnapshot.Packed data = user.createSnapshot(DataSnapshot.SaveCause.DISCONNECT);
plugin.getRedisManager().setUserData(user, data);
plugin.getRedisManager().setUserCheckedOut(user, false);
plugin.getDatabase().addSnapshot(user, data);
});
}
}

@ -158,7 +158,7 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo
} }
plugin.fireEvent( plugin.fireEvent(
plugin.getSyncCompleteEvent(this), plugin.getSyncCompleteEvent(this),
(event) -> plugin.getLockedPlayers().remove(getUuid()) (event) -> plugin.unlockPlayer(getUuid())
); );
} else { } else {
cause.getFailedLocale(plugin).ifPresent(this::sendMessage); cause.getFailedLocale(plugin).ifPresent(this::sendMessage);

@ -70,6 +70,8 @@ public class DataSnapshotOverview {
} }
locales.getLocale("data_manager_cause", snapshot.getSaveCause().getDisplayName()) locales.getLocale("data_manager_cause", snapshot.getSaveCause().getDisplayName())
.ifPresent(user::sendMessage); .ifPresent(user::sendMessage);
locales.getLocale("data_manager_server", snapshot.getServerName())
.ifPresent(user::sendMessage);
// User status data, if present in the snapshot // User status data, if present in the snapshot
final Optional<Data.Health> health = snapshot.getHealth(); final Optional<Data.Health> health = snapshot.getHealth();

@ -11,6 +11,7 @@ data_manager_title: '[Преглеждане потребителският сн
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Клеймо на Версията:\n&8Когато данните са били запазени)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Клеймо на Версията:\n&8Когато данните са били запазени)'
data_manager_pinned: '[※ Закачен снапшот](#d8ff2b show_text=&7Закачен:\n&8Снапшота на този потребител няма да бъде автоматично завъртан.)' data_manager_pinned: '[※ Закачен снапшот](#d8ff2b show_text=&7Закачен:\n&8Снапшота на този потребител няма да бъде автоматично завъртан.)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Причина на Запазване:\n&8Какво е накарало данните да бъдат запазени)' data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Причина на Запазване:\n&8Какво е накарало данните да бъдат запазени)'
data_manager_server: '[Ⓢ %1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Точки кръв) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Точки глад) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7Ниво опит) [🏹 %5%](dark_aqua show_text=&7Режим на игра)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Точки кръв) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Точки глад) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7Ниво опит) [🏹 %5%](dark_aqua show_text=&7Режим на игра)'
data_manager_advancements_statistics: '[⭐ Напредъци: %1%](color=#ffc43b-#f5c962 show_text=&7Напредъци, в които имате прогрес:\n&8%2%) [⌛ Изиграно Време: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Изиграно време в играта\n&8⚠ Базирано на статистики от играта)\n' data_manager_advancements_statistics: '[⭐ Напредъци: %1%](color=#ffc43b-#f5c962 show_text=&7Напредъци, в които имате прогрес:\n&8%2%) [⌛ Изиграно Време: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Изиграно време в играта\n&8⚠ Базирано на статистики от играта)\n'

@ -11,6 +11,7 @@ data_manager_title: '[Du siehst den Nutzerdaten-Schnappschuss](#00fb9a) [%1%](#0
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Versions-Zeitstempel:\n&8Zeitpunkt der Speicherung der Daten)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Versions-Zeitstempel:\n&8Zeitpunkt der Speicherung der Daten)'
data_manager_pinned: '[※ Schnappschuss angeheftet](#d8ff2b show_text=&7Angeheftet:\n&8Dieser Nutzerdaten-Schnappschuss wird nicht automatisch rotiert.)' data_manager_pinned: '[※ Schnappschuss angeheftet](#d8ff2b show_text=&7Angeheftet:\n&8Dieser Nutzerdaten-Schnappschuss wird nicht automatisch rotiert.)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Speicherungsgrund:\n&8Der Grund für das Speichern der Daten)' data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Speicherungsgrund:\n&8Der Grund für das Speichern der Daten)'
data_manager_server: '[Ⓢ %1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Lebenspunkte) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Hungerpunkte) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP-Level) [🏹 %5%](dark_aqua show_text=&7Spielmodus)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Lebenspunkte) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Hungerpunkte) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP-Level) [🏹 %5%](dark_aqua show_text=&7Spielmodus)'
data_manager_advancements_statistics: '[⭐ Erfolge: %1%](color=#ffc43b-#f5c962 show_text=&7Erfolge in denen du Fortschritt gemacht hast:\n&8%2%) [⌛ Spielzeit: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Deine verbrachte Zeit im Spiel\n&8⚠ Basierend auf Spielstatistiken)\n' data_manager_advancements_statistics: '[⭐ Erfolge: %1%](color=#ffc43b-#f5c962 show_text=&7Erfolge in denen du Fortschritt gemacht hast:\n&8%2%) [⌛ Spielzeit: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Deine verbrachte Zeit im Spiel\n&8⚠ Basierend auf Spielstatistiken)\n'

@ -11,6 +11,7 @@ data_manager_title: '[Viewing user data snapshot](#00fb9a) [%1%](#00fb9a show_te
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Version timestamp:\n&8When the data was saved)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Version timestamp:\n&8When the data was saved)'
data_manager_pinned: '[※ Snapshot pinned](#d8ff2b show_text=&7Pinned:\n&8This user data snapshot won''t be automatically rotated.)' data_manager_pinned: '[※ Snapshot pinned](#d8ff2b show_text=&7Pinned:\n&8This user data snapshot won''t be automatically rotated.)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved)' data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved)'
data_manager_server: '[Ⓢ %1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Health points) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Hunger points) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP level) [🏹 %5%](dark_aqua show_text=&7Game mode)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Health points) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Hunger points) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP level) [🏹 %5%](dark_aqua show_text=&7Game mode)'
data_manager_advancements_statistics: '[⭐ Advancements: %1%](color=#ffc43b-#f5c962 show_text=&7Advancements you have progress in:\n&8%2%) [⌛ Play Time: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7In-game play time\n&8⚠ Based on in-game statistics)\n' data_manager_advancements_statistics: '[⭐ Advancements: %1%](color=#ffc43b-#f5c962 show_text=&7Advancements you have progress in:\n&8%2%) [⌛ Play Time: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7In-game play time\n&8⚠ Based on in-game statistics)\n'

@ -11,6 +11,7 @@ data_manager_title: '[Viendo una snapshot sobre la informacion del jugador](#00f
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Version del registro:\n&8Cuando los datos se han guardado)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Version del registro:\n&8Cuando los datos se han guardado)'
data_manager_pinned: '[※ Snapshot anclada](#d8ff2b show_text=&Anclado:\n&8La informacion de este jugador no se rotará automaticamente.)' data_manager_pinned: '[※ Snapshot anclada](#d8ff2b show_text=&Anclado:\n&8La informacion de este jugador no se rotará automaticamente.)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Motivo del guardado:\n&8Lo que ha causado que se guarde)' data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Motivo del guardado:\n&8Lo que ha causado que se guarde)'
data_manager_server: '[Ⓢ %1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Puntos de vida) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Puntos de hambre) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7Nivel de exp) [🏹 %5%](dark_aqua show_text=&7Gamemode)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Puntos de vida) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Puntos de hambre) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7Nivel de exp) [🏹 %5%](dark_aqua show_text=&7Gamemode)'
data_manager_advancements_statistics: '[⭐ Logros: %1%](color=#ffc43b-#f5c962 show_text=&7Logros que has conseguido:\n&8%2%) [⌛ Tiempo de juego: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7In-game play time\n&8⚠ Based on in-game statistics)\n' data_manager_advancements_statistics: '[⭐ Logros: %1%](color=#ffc43b-#f5c962 show_text=&7Logros que has conseguido:\n&8%2%) [⌛ Tiempo de juego: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7In-game play time\n&8⚠ Based on in-game statistics)\n'

@ -11,6 +11,7 @@ data_manager_title: '[Stai vedendo l''istantanea](#00fb9a) [%1%](#00fb9a show_te
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7:\n&8Quando i dati sono stati salvati)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7:\n&8Quando i dati sono stati salvati)'
data_manager_pinned: '[※ Istantanea fissata](#d8ff2b show_text=&7Pinned:\n&8Quest''istantanea non sarà cancellata automaticamente.)' data_manager_pinned: '[※ Istantanea fissata](#d8ff2b show_text=&7Pinned:\n&8Quest''istantanea non sarà cancellata automaticamente.)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Causa di salvataggio:\n&8Cosa ha causato il salvataggio dei dati)' data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Causa di salvataggio:\n&8Cosa ha causato il salvataggio dei dati)'
data_manager_server: '[Ⓢ %1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Peso dell''istantanea:\n&8Peso stimato del file (in KiB))\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Peso dell''istantanea:\n&8Peso stimato del file (in KiB))\n'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Vita) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Fame) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7Livello di XP) [🏹 %5%](dark_aqua show_text=&7Modalità di gioco)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Vita) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Fame) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7Livello di XP) [🏹 %5%](dark_aqua show_text=&7Modalità di gioco)'
data_manager_advancements_statistics: '[⭐ Progressi: %1%](color=#ffc43b-#f5c962 show_text=&7Progressi compiuti in:\n&8%2%) [⌛ Tempo di gioco: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Tempo di gioco\n&8⚠ Basato sulle statistiche di gioco)\n' data_manager_advancements_statistics: '[⭐ Progressi: %1%](color=#ffc43b-#f5c962 show_text=&7Progressi compiuti in:\n&8%2%) [⌛ Tempo di gioco: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Tempo di gioco\n&8⚠ Basato sulle statistiche di gioco)\n'

@ -11,6 +11,7 @@ data_manager_title: '[%3%](#00fb9a bold show_text=&7プレイヤーUUID:\n&8%4%)
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7バージョンタイムスタンプ:\n&8データの保存時期)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7バージョンタイムスタンプ:\n&8データの保存時期)'
data_manager_pinned: '[※ ピン留めされたスナップショット](#d8ff2b show_text=&7ピン留め:\n&8このユーザーデータのスナップショットは自動的にローテーションされません。)' data_manager_pinned: '[※ ピン留めされたスナップショット](#d8ff2b show_text=&7ピン留め:\n&8このユーザーデータのスナップショットは自動的にローテーションされません。)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7保存理由:\n&8データが保存された理由)' data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7保存理由:\n&8データが保存された理由)'
data_manager_server: '[Ⓢ %1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7スナップショットサイズ:\n&8スナップショットの推定ファイルサイズ単位:KiB)\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7スナップショットサイズ:\n&8スナップショットの推定ファイルサイズ単位:KiB)\n'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7体力) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7空腹度) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7経験値レベル) [🏹 %5%](dark_aqua show_text=&7ゲームモード)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7体力) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7空腹度) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7経験値レベル) [🏹 %5%](dark_aqua show_text=&7ゲームモード)'
data_manager_advancements_statistics: '[⭐ 進捗: %1%](color=#ffc43b-#f5c962 show_text=&7達成した進捗:\n&8%2%) [⌛ プレイ時間: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7ゲーム内のプレイ時間\n&8⚠ ゲーム内の統計に基づく)\n' data_manager_advancements_statistics: '[⭐ 進捗: %1%](color=#ffc43b-#f5c962 show_text=&7達成した進捗:\n&8%2%) [⌛ プレイ時間: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7ゲーム内のプレイ時間\n&8⚠ ゲーム内の統計に基づく)\n'

@ -11,6 +11,7 @@ data_manager_title: '[Momentopname van gebruikersgegevens bekijken](#00fb9a) [%1
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Versie tijdmarkering:\n&8Toen de gegevens werden opgeslagen)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Versie tijdmarkering:\n&8Toen de gegevens werden opgeslagen)'
data_manager_pinned: '[※ Momentopname vastgezet](#d8ff2b show_text=&7Vastgezet:\n&8Deze momentopname van gebruikersgegevens wordt niet automatisch gerouleerd.)' data_manager_pinned: '[※ Momentopname vastgezet](#d8ff2b show_text=&7Vastgezet:\n&8Deze momentopname van gebruikersgegevens wordt niet automatisch gerouleerd.)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Reden opslaan:\n&8Waarom de data is opgeslagen)' data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Reden opslaan:\n&8Waarom de data is opgeslagen)'
data_manager_server: '[Ⓢ %1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Grootte van momentopname:\n&8Geschatte bestandsgrootte van de momentopname (in KiB))\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Grootte van momentopname:\n&8Geschatte bestandsgrootte van de momentopname (in KiB))\n'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Gezondheids punten) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Honger punten) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP level) [🏹 %5%](dark_aqua show_text=&7Speltype)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Gezondheids punten) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Honger punten) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP level) [🏹 %5%](dark_aqua show_text=&7Speltype)'
data_manager_advancements_statistics: '[⭐ Advancements: %1%](color=#ffc43b-#f5c962 show_text=&7Advancements waarin je voortgang hebt:\n&8%2%) [⌛ Speeltijd: %3%uren](color=#62a9f5-#7ab8fa show_text=&7In-game speeltijd\n&8⚠ Gebaseerd op in-game statistieken)\n' data_manager_advancements_statistics: '[⭐ Advancements: %1%](color=#ffc43b-#f5c962 show_text=&7Advancements waarin je voortgang hebt:\n&8%2%) [⌛ Speeltijd: %3%uren](color=#62a9f5-#7ab8fa show_text=&7In-game speeltijd\n&8⚠ Gebaseerd op in-game statistieken)\n'

@ -11,6 +11,7 @@ data_manager_title: '[Visualizando snapshot dos dados do usuário](#00fb9a) [%1%
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Version timestamp:\n&8Quando os dados foram salvos)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Version timestamp:\n&8Quando os dados foram salvos)'
data_manager_pinned: '[※ Snapshot marcada](#d8ff2b show_text=&7Marcada:\n&8Essa snapshot de dados do usuário não será girada automaticamente.)' data_manager_pinned: '[※ Snapshot marcada](#d8ff2b show_text=&7Marcada:\n&8Essa snapshot de dados do usuário não será girada automaticamente.)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Causa do salvamento:\n&8O motivo para que os dados fossem salvos)' data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Causa do salvamento:\n&8O motivo para que os dados fossem salvos)'
data_manager_server: '[Ⓢ %1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Pontos de Vida) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Pontos de vida) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP level) [🏹 %5%](dark_aqua show_text=&7Game mode)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Pontos de Vida) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Pontos de vida) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP level) [🏹 %5%](dark_aqua show_text=&7Game mode)'
data_manager_advancements_statistics: '[⭐ Progressos: %1%](color=#ffc43b-#f5c962 show_text=&7Progressos que você tem realizado em:\n&8%2%) [⌛ Tempo de jogo: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Tempo de jogo dentro do jogo\n&8⚠ Com base em estatísticas dentro do jogo)\n' data_manager_advancements_statistics: '[⭐ Progressos: %1%](color=#ffc43b-#f5c962 show_text=&7Progressos que você tem realizado em:\n&8%2%) [⌛ Tempo de jogo: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Tempo de jogo dentro do jogo\n&8⚠ Com base em estatísticas dentro do jogo)\n'

@ -11,6 +11,7 @@ data_manager_title: '[Viewing user data snapshot](#00fb9a) [%1%](#00fb9a show_te
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Version timestamp:\n&8When the data was saved)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Version timestamp:\n&8When the data was saved)'
data_manager_pinned: '[※ Snapshot pinned](#d8ff2b show_text=&7Pinned:\n&8This user data snapshot won''t be automatically rotated.)' data_manager_pinned: '[※ Snapshot pinned](#d8ff2b show_text=&7Pinned:\n&8This user data snapshot won''t be automatically rotated.)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved)' data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved)'
data_manager_server: '[Ⓢ %1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Health points) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Hunger points) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP level) [🏹 %5%](dark_aqua show_text=&7Game mode)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Health points) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Hunger points) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP level) [🏹 %5%](dark_aqua show_text=&7Game mode)'
data_manager_advancements_statistics: '[⭐ Advancements: %1%](color=#ffc43b-#f5c962 show_text=&7Advancements you have progress in:\n&8%2%) [⌛ Play Time: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7In-game play time\n&8⚠ Based on in-game statistics)\n' data_manager_advancements_statistics: '[⭐ Advancements: %1%](color=#ffc43b-#f5c962 show_text=&7Advancements you have progress in:\n&8%2%) [⌛ Play Time: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7In-game play time\n&8⚠ Based on in-game statistics)\n'

@ -11,6 +11,7 @@ data_manager_title: '[正在查看玩家](#00fb9a) [%3%](#00fb9a bold show_text=
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7备份时间:\n&7何时保存了此数据)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7备份时间:\n&7何时保存了此数据)'
data_manager_pinned: '[※ 置顶备份](#d8ff2b show_text=&7置顶:\n&8此数据备份不会按照备份时间自动排序.)' data_manager_pinned: '[※ 置顶备份](#d8ff2b show_text=&7置顶:\n&8此数据备份不会按照备份时间自动排序.)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7备份原因:\n&7为何保存了此数据)' data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7备份原因:\n&7为何保存了此数据)'
data_manager_server: '[Ⓢ %1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7血量) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7饱食度) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7经验等级) [🏹 %5%](dark_aqua show_text=&7游戏模式)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7血量) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7饱食度) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7经验等级) [🏹 %5%](dark_aqua show_text=&7游戏模式)'
data_manager_advancements_statistics: '[⭐ 成就: %1%](color=#ffc43b-#f5c962 show_text=&7%2%) [⌛ 游玩时间: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7⚠ 基于游戏内的统计)\n' data_manager_advancements_statistics: '[⭐ 成就: %1%](color=#ffc43b-#f5c962 show_text=&7%2%) [⌛ 游玩时间: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7⚠ 基于游戏内的统计)\n'

@ -11,6 +11,7 @@ data_manager_title: '[查看](#00fb9a) [%3%](#00fb9a bold show_text=&7玩家 UUI
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7快照時間:\n&8何時保存的資料)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7快照時間:\n&8何時保存的資料)'
data_manager_pinned: '[※ 被標記的快照](#d8ff2b show_text=&7標記:\n&8此快照資料不會自動輪換更新)' data_manager_pinned: '[※ 被標記的快照](#d8ff2b show_text=&7標記:\n&8此快照資料不會自動輪換更新)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7保存原因:\n&8保存此快照的原因)' data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7保存原因:\n&8保存此快照的原因)'
data_manager_server: '[Ⓢ %1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7血量) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7飽食度) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7經驗等級) [🏹 %5%](dark_aqua show_text=&7遊戲模式)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7血量) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7飽食度) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7經驗等級) [🏹 %5%](dark_aqua show_text=&7遊戲模式)'
data_manager_advancements_statistics: '[⭐ 成就: %1%](color=#ffc43b-#f5c962 show_text=&7已獲得的成就:\n&8%2%) [⌛ 遊戲時間: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7遊戲內的遊玩時間\n&8⚠ 根據遊戲內統計)\n' data_manager_advancements_statistics: '[⭐ 成就: %1%](color=#ffc43b-#f5c962 show_text=&7已獲得的成就:\n&8%2%) [⌛ 遊戲時間: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7遊戲內的遊玩時間\n&8⚠ 根據遊戲內統計)\n'

@ -1,6 +1,12 @@
This page contains the configuration file reference for HuskSync. The config file is located in `/plugins/HuskSync/config.yml` This page contains the configuration structure for HuskSync.
## Example config ## Configuration structure
📁 `plugins/HuskSync/`
- 📄 `config.yml`: General plugin configuration
- 📄 `server.yml`: Server ID configuration
- 📄 `messages-xx-xx.yml`: Plugin locales, formatted in MineDown (see [[Translations]])
## Example files
<details> <details>
<summary>config.yml</summary> <summary>config.yml</summary>
@ -12,7 +18,7 @@ This page contains the configuration file reference for HuskSync. The config fil
# ┣╸ Information: https://william278.net/project/husksync # ┣╸ Information: https://william278.net/project/husksync
# ┣╸ Config Help: https://william278.net/docs/husksync/config-file/ # ┣╸ Config Help: https://william278.net/docs/husksync/config-file/
# ┗╸ Documentation: https://william278.net/docs/husksync # ┗╸ Documentation: https://william278.net/docs/husksync
# Locale of the default language file to use. Docs: https://william278.net/docs/huskhomes/translations # Locale of the default language file to use. Docs: https://william278.net/docs/husksync/translations
language: en-gb language: en-gb
# Whether to automatically check for plugin updates on startup # Whether to automatically check for plugin updates on startup
check_for_updates: true check_for_updates: true
@ -54,6 +60,8 @@ redis:
password: '' password: ''
use_ssl: false use_ssl: false
synchronization: synchronization:
# The mode of data synchronization to use (DELAY or LOCKSTEP). DELAY should be fine for most networks. Docs: https://william278.net/docs/husksync/sync-modes
mode: DELAY
# The number of data snapshot backups that should be kept at once per user # The number of data snapshot backups that should be kept at once per user
max_user_data_snapshots: 16 max_user_data_snapshots: 16
# Number of hours between new snapshots being saved as backups (Use "0" to backup all snapshots) # Number of hours between new snapshots being saved as backups (Use "0" to backup all snapshots)
@ -63,7 +71,6 @@ synchronization:
- INVENTORY_COMMAND - INVENTORY_COMMAND
- ENDERCHEST_COMMAND - ENDERCHEST_COMMAND
- BACKUP_RESTORE - BACKUP_RESTORE
- CONVERTED_FROM_V2
- LEGACY_MIGRATION - LEGACY_MIGRATION
- MPDB_MIGRATION - MPDB_MIGRATION
# Whether to create a snapshot for users on a world when the server saves that world # Whether to create a snapshot for users on a world when the server saves that world
@ -82,7 +89,7 @@ synchronization:
synchronize_max_health: true 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). # If using the DELAY sync method, how long should this server listen for Redis key data updates before pulling data from the database instead (i.e., if the user did not change servers).
network_latency_milliseconds: 500 network_latency_milliseconds: 500
# Which data types to synchronize (Docs: https://william278.net/docs/husksync/sync-features) # Which data types to synchronize (Docs: https://william278.net/docs/husksync/sync-features)
features: features:
@ -109,5 +116,17 @@ synchronization:
</details> </details>
## Messages files <details>
You can customize the plugin locales, too, by editing your `messages-xx-xx.yml` file. This file is formatted using [MineDown syntax](https://github.com/Phoenix616/MineDown). For more information, see [[Translations]]. <summary>server.yml</summary>
```yaml
# ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
# ┃ HuskSync Server ID config ┃
# ┃ Developed by William278 ┃
# ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
# ┣╸ This file should contain the ID of this server as defined in your proxy config.
# ┗╸ If you join it using /server alpha, then set it to 'alpha' (case-sensitive)
name: beta
```
</details>

@ -4,6 +4,8 @@ To do this, you create and register an implementation of a platform `Data` class
> **Note:** Before you begin, consider if this is what you'd like to do. For simpler/smaller data sync tasks you may wish to consider using the PersistentDataContainer API format instead, which is a bit more portable if you decide to exit the HuskSync ecosystem. > **Note:** Before you begin, consider if this is what you'd like to do. For simpler/smaller data sync tasks you may wish to consider using the PersistentDataContainer API format instead, which is a bit more portable if you decide to exit the HuskSync ecosystem.
If you'd like to have a look at an example of a data extension for HuskSync that provides serializers for Pixelmon data when running the plugin on Arclight, check out [PokeSync by GsTio86](https://github.com/GsTio86/PokeSync)!
## Table of Contents ## Table of Contents
1. [Extending the BukkitData Class](#1-extending-the-bukkitdata-class) 1. [Extending the BukkitData Class](#1-extending-the-bukkitdata-class)
1. [Implementing Adaptable](#11-implementing-adaptable) 1. [Implementing Adaptable](#11-implementing-adaptable)

@ -33,9 +33,7 @@ HuskSync requires Redis to operate (for reasons demonstrated below). Redis is an
<details> <details>
<summary>&nbsp;<b>How does the plugin synchronize data?</b></summary> <summary>&nbsp;<b>How does the plugin synchronize data?</b></summary>
![System diagram](https://raw.githubusercontent.com/WiIIiam278/HuskSync/master/images/system-diagram.png) HuskSync makes use of both MySQL and Redis for optimal data synchronization. You have the option of using one of two [[Sync Modes]], which synchronize data between servers (`DELAY` or `LOCKSTEP`)
HuskSync makes use of both MySQL and Redis for optimal data synchronization.
When a user changes servers, in addition to data being saved to MySQL, it is also cached via the Redis server with a temporary expiry key. When changing servers, the receiving server detects the key and sets the user data from Redis. When a player rejoins the network, the system fetches the last-saved data snapshot from the MySQL Database. When a user changes servers, in addition to data being saved to MySQL, it is also cached via the Redis server with a temporary expiry key. When changing servers, the receiving server detects the key and sets the user data from Redis. When a player rejoins the network, the system fetches the last-saved data snapshot from the MySQL Database.

@ -14,6 +14,7 @@ Welcome! This is the plugin documentation for HuskSync v3.x+. Please click throu
## Documentation ## Documentation
* 🖥️ [[Commands]] * 🖥️ [[Commands]]
* ✅ [[Sync Features]] * ✅ [[Sync Features]]
* ⚙️ [[Sync Modes]]
* 🟩 [[Plan Hook]] * 🟩 [[Plan Hook]]
* ☂️ [[Dumping UserData]] * ☂️ [[Dumping UserData]]
* 📋 [[Event Priorities]] * 📋 [[Event Priorities]]

@ -17,8 +17,11 @@ This will walk you through installing HuskSync on your network of Spigot servers
- Navigate to the HuskSync config file on each server (`~/plugins/HuskSync/config.yml`) - Navigate to the HuskSync config file on each server (`~/plugins/HuskSync/config.yml`)
- Under `credentials` in the `database` section, enter the credentials of your MySQL Database. You shouldn't touch the `connection_pool` properties. - Under `credentials` in the `database` section, enter the credentials of your MySQL Database. You shouldn't touch the `connection_pool` properties.
- Under `credentials` in the `redis` section, enter the credentials of your Redis Database. If your Redis server doesn't have a password, leave the password blank as it is. - Under `credentials` in the `redis` section, enter the credentials of your Redis Database. If your Redis server doesn't have a password, leave the password blank as it is.
- Unless you want to have multiple clusters of servers within your network, each with separate user data, do not change the value of `cluster_id`. - Unless you want to have multiple clusters of servers within your network, each with separate user data, you should not change the value of `cluster_id`.
### 4. Start every server again ### 4. Set server names in server.yml files
- Navigate to the HuskSync server name file on each server (`~/plugins/HuskSync/server.yml`)
- Set the `name:` of the server in this file to the ID of this server as defined in the config of your proxy (e.g., if this is the "hub" server you access with `/server hub`, put `'hub'` here)
### 5. Start every server again
- Provided your MySQL and Redis credentials were correct, synchronization should begin as soon as you start your servers again. - Provided your MySQL and Redis credentials were correct, synchronization should begin as soon as you start your servers again.
- If you need to import data from HuskSync v1.x or MySQLPlayerDataBridge, please see the guides below: - If you need to import data from HuskSync v1.x or MySQLPlayerDataBridge, please see the guides below:
- [[Legacy Migration]] - [[Legacy Migration]]

@ -0,0 +1,44 @@
HuskSync offers two built-in **synchronization modes**. These sync modes change the way data is synced between servers. This page details the two sync modes available and how they work.
* The `DELAY` sync mode is the default sync mode, that use the `network_latency_miliseconds` value to apply a delay before listening to Redis data
* The `LOCKSTEP` sync mode uses a data checkout system to ensure that all servers are in sync regardless of network latency or tick rate fluctuations. This mode was introduced in HuskSync v3.1
You can change which sync mode you are using by editing the `sync_mode` setting under `synchronization` in `config.yml`.
> **Warning:** Please note that you must use the same sync mode on all servers (at least within a cluster).
<details>
<summary>Changing the sync mode (config.yml)</summary>
```yaml
synchronization:
# The mode of data synchronization to use (DELAY or LOCKSTEP). DELAY should be fine for most networks. Docs: https://william278.net/docs/husksync/sync-modes
mode: DELAY
```
</details>
## Delay
![Delay diagram](https://raw.githubusercontent.com/WiIIiam278/HuskSync/master/images/system-diagram.png)
The `DELAY` sync mode works as described below:
* When a user disconnects from a server, a `SERVER_SWITCH` key is immediately set on Redis, followed by a `DATA_UPDATE` key which contains the user's packed and serialized Data Snapshot.
* When the user connects to a server, they are marked as locked (unable to break blocks, use containers, etc.)
* The server asynchronously waits for the `network_latency_miliseconds` value (default: 500ms) to allow the source server time to serialize & set their key.
* After waiting, the server checks for the `SERVER_SWITCH` key.
* If present, it will continuously attempt to read for a `DATA_UPDATE` key; when read, their data will be set from the snapshot deserialized from Redis.
* If not present, their data will be pulled from the database (as though they joined the network)
`DELAY` has been the default sync mode since HuskSync v2.0. In HuskSync v3.1, `LOCKSTEP` was introduced. Since the delay mode has been tested and deployed for the longest, it is still the default, though note this may change in the future.
However, if your network has a fluctuating tick rate or significant latency (especially if you have servers on different hardware/locations), you may wish to use `LOCKSTEP` instead for a more reliable sync system.
## Lockstep
The `LOCKSTEP` sync mode works as described below:
* When a user connects to a server, the server will continuously asynchronously check if a `DATA_CHECKOUT` key is present.
* If, or when, the key is not present, the plugin will set a new `DATA_CHECKOUT` key.
* After this, the plugin will check Redis for the presence of a `DATA_UPDATE` key.
* If a `DATA_UPDATE` key is present, the user's data will be set from the snapshot deserialized from Redis contained within that key.
* Otherwise, their data will be pulled from the database.
* When a user disconnects from a server, their data is serialized and set to Redis with a `DATA_UPDATE` key. After this key has been set, the user's current `DATA_CHECKOUT` key will be removed from Redis.
Additionally, note that `DATA_CHECKOUT` keys are set with the server ID of the server which "checked out" the data (taken from the `server.yml` config file). On both shutdown and startup, the plugin will clear all `DATA_CHECKOUT` keys for the current server ID (to prevent stale keys in the event of a server crash for instance)

@ -3,10 +3,10 @@ HuskSync supports a number of community-sourced translations of the plugin local
You can change which preset language option to use by changing the top-level `language` setting in the plugin config.yml file. You must change this to one of the supported language codes. You can [view a list of the supported languages](https://github.com/WiIIiam278/HuskSync/tree/master/common/src/main/resources/locales) by looking at the locales source folder. You can change which preset language option to use by changing the top-level `language` setting in the plugin config.yml file. You must change this to one of the supported language codes. You can [view a list of the supported languages](https://github.com/WiIIiam278/HuskSync/tree/master/common/src/main/resources/locales) by looking at the locales source folder.
## Contributing Locales ## Contributing Locales
You can contribute locales by submitting a pull request with a yaml file containing translations of the [default locales](https://github.com/WiIIiam278/HuskSync/blob/master/common/src/main/resources/locales/en-gb.yml) into your language. Here's a few pointers for doing this: You can contribute locales by submitting a pull request with a yaml file containing translations of the [default locales](https://github.com/WiIIiam278/HuskSync/blob/master/common/src/main/resources/locales/en-gb.yml) into your language. Here are a few pointers for doing this:
* Do not translate the locale keys themselves (e.g. `teleporting_offline_player`) * Do not translate the locale keys themselves (e.g. `synchronization_complete`)
* Your pull request should be for a file in the [locales folder](https://github.com/WiIIiam278/HuskSync/tree/master/common/src/main/resources/locales) * Your pull request should be for a file in the [locales folder](https://github.com/WiIIiam278/HuskSync/tree/master/common/src/main/resources/locales)
* Do not translate the [MineDown](https://github.com/Phoenix616/MineDown) markdown syntax itself or commands and their parameters; only the english interface text * Do not translate the [MineDown](https://github.com/Phoenix616/MineDown) Markdown syntax itself or commands and their parameters; only the english interface text
* Each locale should be on one line, and the header should be removed. * Each locale should be on one line, and the header should be removed.
* Use the correct ISO 639-1 [locale code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) for your language and dialect * Use the correct ISO 639-1 [locale code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) for your language and dialect
* If you are able to, you can add your name to the `AboutMenu` translators credit list yourself, otherwise this can be done for you * If you are able to, you can add your name to the `AboutMenu` translators credit list yourself, otherwise this can be done for you

@ -11,6 +11,7 @@
## Documentation ## Documentation
* 🖥️ [[Commands]] * 🖥️ [[Commands]]
* ✅ [[Sync Features]] * ✅ [[Sync Features]]
* ⚙️ [[Sync Modes]]
* 🟩 [[Plan Hook]] * 🟩 [[Plan Hook]]
* ☂️ [[Dumping UserData]] * ☂️ [[Dumping UserData]]
* 📋 [[Event Priorities]] * 📋 [[Event Priorities]]

@ -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.2 plugin_version=3.1
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

Loading…
Cancel
Save