9
0
mirror of https://github.com/WiIIiam278/HuskSync.git synced 2025-12-28 02:59:13 +00:00

v3.0: New modular, more compatible data format, new API, better UX (#160)

* Start work on v3

* More work on task scheduling

* Add comment to notification display slot

* Synchronise branches

* Use new HuskHomes-style task system

* Bump to 2.3

* Remove HuskSyncInitializationException.java

* Optimise database for MariaDB

* Update libraries, move some around

* Tweak command registration

* Remove dummyhusksync

* Fixup core synchronisation logic to use new task system

* Implement new event dispatch subsystem

* Remove last remaining future calls

* Remove `Event#fire()`

* Refactor startup process

* New command subsystem, more initialization improvements, locale fixes

* Update docs, tweak command perms

* Reduce task number during data setting

* add todo

* Start work on data format / serialization refactor

* More work on Bukkit impl

* More serialization work

* Fixes to serialization, data preview system

* Start legacy conversion skeleton

* Handle setting empty inventories

* Start on-the-fly legacy conversion work

* Add advancement conversion

* Rewrite advancement get / apply logic

* Start work on locked map persistence

* More map persistence work

* More work on map serialization

* Move around persistence logic

* Add testing suite

* Fix item synchronisation

* Finalize more reliable locked map persistence

* Remove deprecated method call

* remove sync feature enum

* Fix held item slot syncing

* Make data types modular and API-extensible

* Remove some excessive debugging, minor refactor

* Fixup date formatting, improve menu UIs

* Finish up legacy data converting

* Null safety in item stack serializaiton

* Fix relocation of nbtapi, update dumping docs

* Add v1/MPDB Migrators back in

* Fix pinning/unpinning data not working

* Consumer instead of Function for editing data

* Show file size in DataSnapshotOverview

* Fix getIdentifier always returning empty

* Re-add items and inventory GUI commands

* Improve config file, fixup data restoration

* Add min time between backups (more useful backups!)

* More work on backups

* Fixup backup rotation frequency

* Remove stdout debug print in `#getEventPriority`

* Improve sync complete locale logic, fix synchronization spelling

* Remove `static` on exception

* Use dedicated thread for Redis, properly unsubscribe

* Refactor `player` package -> `user`

* `PlayerDataHolder` -> `UserDataHolder`

* Make `StatisticsMap` public, but `@ApiStatus.Internal`

* Suppress unused warnings on `Data`

* Add option to disable Plan hook

* Decompress legacy data before converting

* Decompress bytes in fromBytes

* Check permission node before serving TAB suggestions

* Actually convert legacy item stack data

* Fix syntax errors

* Minor method refactor in items command

* Fixup case-sensitive parsing in HuskSync command

* Start API work

* More work on API, fix potion effects

* Fix cross-server, config formatting for auto-pinned issue

* Fix confusion with UserData command, update docs images

* Update commands docs

* More docs updating

* Fix sync feature enabled/disabled checking logic

* Fix `#isCustom()`

* Enable persistent_data syncing by default

* docs: update Sync-Features config snippet

* docs: correct typo in Sync Features

* More API work

* bukkit: slightly optimized schedulers

* More API work, various refactorings

* docs: Start new API docs

* bump dependencies

* Add some basic unit tests

* docs: Correct typos

* More docs work, annotate DB methods as `@Blocking`

* Encapsulate `RedisMessage`, minor optimisations

* api: Simplify `#getCurrentData`

* api: Simplify `editCurrentData`, using `ThrowingConsumers` for better error handling

* docs: More Data Snapshot API documenting

* docs: add TOC to Data Snapshot API page

* bukkit: Make data types extend BukkitData

* Move where custom data is stored, finish up Custom Data API docs

* Optimise imports

* Fix `data_manager_advancements_preview_remaining` locale

* Fix advancement and playtime previews

* Fix potion effect deserialization

* Make snapshot_backup_frequency default to 4, more error handling/logging

* docs: Add ToC to Custom Data API

* docs: Minor legacy API tweaks

* Remove some unneeded catch logic

* Suppress a few warnings

* Fix Effect constructor being supplied in wrong order
This commit is contained in:
William
2023-09-20 14:02:26 +01:00
committed by GitHub
parent 9018ad02e1
commit 105f65c93a
149 changed files with 10067 additions and 7470 deletions

View File

@@ -19,31 +19,40 @@
package net.william278.husksync;
import com.fatboyindustrial.gsonjavatime.Converters;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import net.william278.annotaml.Annotaml;
import net.william278.desertwell.util.ThrowingConsumer;
import net.william278.desertwell.util.UpdateChecker;
import net.william278.desertwell.util.Version;
import net.william278.husksync.adapter.DataAdapter;
import net.william278.husksync.config.Locales;
import net.william278.husksync.config.Settings;
import net.william278.husksync.data.DataAdapter;
import net.william278.husksync.data.Data;
import net.william278.husksync.data.Identifier;
import net.william278.husksync.data.Serializer;
import net.william278.husksync.database.Database;
import net.william278.husksync.event.EventCannon;
import net.william278.husksync.event.EventDispatcher;
import net.william278.husksync.migrator.Migrator;
import net.william278.husksync.player.OnlineUser;
import net.william278.husksync.redis.RedisManager;
import net.william278.husksync.user.ConsoleUser;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.util.LegacyConverter;
import net.william278.husksync.util.Task;
import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.lang.reflect.InvocationTargetException;
import java.util.*;
import java.util.logging.Level;
/**
* Abstract implementation of the HuskSync plugin.
*/
public interface HuskSync {
public interface HuskSync extends Task.Supplier, EventDispatcher {
int SPIGOT_RESOURCE_ID = 97144;
@@ -81,21 +90,45 @@ public interface HuskSync {
@NotNull
RedisManager getRedisManager();
/**
* Returns the data adapter implementation
*
* @return the {@link DataAdapter} implementation
*/
@NotNull
DataAdapter getDataAdapter();
/**
* Returns the event firing cannon
*
* @return the {@link EventCannon} implementation
* Returns the data serializer for the given {@link Identifier}
*/
@NotNull
EventCannon getEventCannon();
<T extends Data> Map<Identifier, Serializer<T>> getSerializers();
/**
* Register a data serializer for the given {@link Identifier}
*
* @param identifier the {@link Identifier}
* @param serializer the {@link Serializer}
*/
default void registerSerializer(@NotNull Identifier identifier,
@NotNull Serializer<? extends Data> serializer) {
if (identifier.isCustom()) {
log(Level.INFO, String.format("Registered custom data type: %s", identifier));
}
getSerializers().put(identifier, (Serializer<Data>) serializer);
}
/**
* Get the {@link Identifier} for the given key
*/
default Optional<Identifier> getIdentifier(@NotNull String key) {
return getSerializers().keySet().stream().filter(identifier -> identifier.toString().equals(key)).findFirst();
}
/**
* Get the set of registered data types
*
* @return the set of registered data types
*/
@NotNull
default Set<Identifier> getRegisteredDataTypes() {
return getSerializers().keySet();
}
/**
* Returns a list of available data {@link Migrator}s
@@ -105,6 +138,25 @@ public interface HuskSync {
@NotNull
List<Migrator> getAvailableMigrators();
@NotNull
Map<Identifier, Data> getPlayerCustomDataStore(@NotNull OnlineUser user);
/**
* Initialize a faucet of the plugin.
*
* @param name the name of the faucet
* @param runner a runnable for initializing the faucet
*/
default void initialize(@NotNull String name, @NotNull ThrowingConsumer<HuskSync> runner) {
log(Level.INFO, "Initializing " + name + "...");
try {
runner.accept(this);
} catch (Throwable e) {
throw new FailedToLoadException("Failed to initialize " + name, e);
}
log(Level.INFO, "Successfully initialized " + name);
}
/**
* Returns the plugin {@link Settings}
*
@@ -113,6 +165,8 @@ public interface HuskSync {
@NotNull
Settings getSettings();
void setSettings(@NotNull Settings settings);
/**
* Returns the plugin {@link Locales}
*
@@ -121,6 +175,16 @@ public interface HuskSync {
@NotNull
Locales getLocales();
void setLocales(@NotNull Locales locales);
/**
* Returns if a dependency is loaded
*
* @param name the name of the dependency
* @return {@code true} if the dependency is loaded, {@code false} otherwise
*/
boolean isDependencyLoaded(@NotNull String name);
/**
* Get a resource as an {@link InputStream} from the plugin jar
*
@@ -129,6 +193,14 @@ public interface HuskSync {
*/
InputStream getResource(@NotNull String name);
/**
* Returns the plugin data folder
*
* @return the plugin data folder as a {@link File}
*/
@NotNull
File getDataFolder();
/**
* Log a message to the console
*
@@ -146,10 +218,18 @@ public interface HuskSync {
*/
default void debug(@NotNull String message, @NotNull Throwable... throwable) {
if (getSettings().doDebugLogging()) {
log(Level.INFO, "[DEBUG] " + message, throwable);
log(Level.INFO, String.format("[DEBUG] %s", message), throwable);
}
}
/**
* Get the console user
*
* @return the {@link ConsoleUser}
*/
@NotNull
ConsoleUser getConsole();
/**
* Returns the plugin version
*
@@ -158,30 +238,6 @@ public interface HuskSync {
@NotNull
Version getPluginVersion();
/**
* Returns the plugin data folder
*
* @return the plugin data folder as a {@link File}
*/
@NotNull
File getDataFolder();
/**
* Returns a future returning the latest plugin {@link Version} if the plugin is out-of-date
*
* @return a {@link CompletableFuture} returning the latest {@link Version} if the current one is out-of-date
*/
default CompletableFuture<Optional<Version>> getLatestVersionIfOutdated() {
return UpdateChecker.builder()
.currentVersion(getPluginVersion())
.endpoint(UpdateChecker.Endpoint.SPIGOT)
.resource(Integer.toString(SPIGOT_RESOURCE_ID)).build()
.check()
.thenApply(checked -> checked.isUpToDate()
? Optional.empty()
: Optional.of(checked.getLatestVersion()));
}
/**
* Returns the Minecraft version implementation
*
@@ -191,12 +247,95 @@ public interface HuskSync {
Version getMinecraftVersion();
/**
* Reloads the {@link Settings} and {@link Locales} from their respective config files
* Returns the platform type
*
* @return a {@link CompletableFuture} that will be completed when the plugin reload is complete and if it was successful
* @return the platform type
*/
CompletableFuture<Boolean> reload();
@NotNull
String getPlatformType();
/**
* Returns the legacy data converter, if it exists
*
* @return the {@link LegacyConverter}
*/
Optional<LegacyConverter> getLegacyConverter();
/**
* Reloads the {@link Settings} and {@link Locales} from their respective config files.
*/
default void loadConfigs() {
try {
// Load settings
setSettings(Annotaml.create(new File(getDataFolder(), "config.yml"), Settings.class).get());
// Load locales from language preset default
final Locales languagePresets = Annotaml.create(
Locales.class,
Objects.requireNonNull(getResource(String.format("locales/%s.yml", getSettings().getLanguage())))
).get();
setLocales(Annotaml.create(new File(
getDataFolder(),
String.format("messages_%s.yml", getSettings().getLanguage())
), languagePresets).get());
} catch (IOException | InvocationTargetException | InstantiationException | IllegalAccessException e) {
throw new FailedToLoadException("Failed to load config or message files", e);
}
}
@NotNull
default UpdateChecker getUpdateChecker() {
return UpdateChecker.builder()
.currentVersion(getPluginVersion())
.endpoint(UpdateChecker.Endpoint.SPIGOT)
.resource(Integer.toString(SPIGOT_RESOURCE_ID))
.build();
}
default void checkForUpdates() {
if (getSettings().doCheckForUpdates()) {
getUpdateChecker().check().thenAccept(checked -> {
if (!checked.isUpToDate()) {
log(Level.WARNING, String.format(
"A new version of HuskSync is available: v%s (running v%s)",
checked.getLatestVersion(), getPluginVersion())
);
}
});
}
}
@NotNull
Set<UUID> getLockedPlayers();
@NotNull
Gson getGson();
@NotNull
default Gson createGson() {
return Converters.registerOffsetDateTime(new GsonBuilder()).create();
}
/**
* An exception indicating the plugin has been accessed before it has been registered.
*/
final class FailedToLoadException extends IllegalStateException {
private static final String FORMAT = """
HuskSync has failed to load! The plugin will not be enabled and no data will be synchronized.
Please make sure the plugin has been setup correctly (https://william278.net/docs/husksync/setup):
1) Make sure you've entered your MySQL or MariaDB database details correctly in config.yml
2) Make sure your Redis server details are also correct in config.yml
3) Make sure your config is up-to-date (https://william278.net/docs/husksync/config-files)
4) Check the error below for more details
Caused by: %s""";
FailedToLoadException(@NotNull String message, @NotNull Throwable cause) {
super(String.format(FORMAT, message), cause);
}
}
}

View File

@@ -17,14 +17,8 @@
* limitations under the License.
*/
package net.william278.husksync.data;
package net.william278.husksync.adapter;
/**
* Indicates an error occurred during {@link UserData} adaptation to and from (compressed) json.
*/
public class DataAdaptionException extends RuntimeException {
protected DataAdaptionException(String message, Throwable cause) {
super(message, cause);
}
public interface Adaptable {
}

View File

@@ -0,0 +1,108 @@
/*
* 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.adapter;
import org.jetbrains.annotations.NotNull;
import java.nio.charset.StandardCharsets;
/**
* An adapter that adapts data to and from a portable byte array.
*/
public interface DataAdapter {
/**
* Converts an {@link Adaptable} to a string.
*
* @param data The {@link Adaptable} to adapt
* @param <A> The type of the {@link Adaptable}
* @return The string
* @throws AdaptionException If an error occurred during adaptation.
*/
@NotNull
default <A extends Adaptable> String toString(@NotNull A data) throws AdaptionException {
return new String(this.toBytes(data), StandardCharsets.UTF_8);
}
/**
* Converts an {@link Adaptable} to a byte array.
*
* @param data The {@link Adaptable} to adapt
* @param <A> The type of the {@link Adaptable}
* @return The byte array
* @throws AdaptionException If an error occurred during adaptation.
*/
<A extends Adaptable> byte[] toBytes(@NotNull A data) throws AdaptionException;
/**
* Converts a JSON string to an {@link Adaptable}.
*
* @param data The JSON string to adapt.
* @param type The class type of the {@link Adaptable} to adapt to.
* @param <A> The type of the {@link Adaptable}
* @return The {@link Adaptable}
* @throws AdaptionException If an error occurred during adaptation.
*/
@NotNull
<A extends Adaptable> A fromJson(@NotNull String data, @NotNull Class<A> type) throws AdaptionException;
/**
* Converts an {@link Adaptable} to a JSON string.
*
* @param data The {@link Adaptable} to adapt
* @param <A> The type of the {@link Adaptable}
* @return The JSON string
* @throws AdaptionException If an error occurred during adaptation.
*/
@NotNull
<A extends Adaptable> String toJson(@NotNull A data) throws AdaptionException;
/**
* Converts a byte array to an {@link Adaptable}.
*
* @param data The byte array to adapt.
* @param type The class type of the {@link Adaptable} to adapt to.
* @param <A> The type of the {@link Adaptable}
* @return The {@link Adaptable}
* @throws AdaptionException If an error occurred during adaptation.
*/
<A extends Adaptable> A fromBytes(@NotNull byte[] data, @NotNull Class<A> type) throws AdaptionException;
/**
* Converts a byte array to a string, including decompression if required.
*
* @param bytes The byte array to convert
* @return the string form of the bytes
*/
@NotNull
String bytesToString(byte[] bytes);
final class AdaptionException extends IllegalStateException {
static final String FORMAT = "An exception occurred when adapting serialized/deserialized data: %s";
public AdaptionException(@NotNull String message, @NotNull Throwable cause) {
super(String.format(FORMAT, message), cause);
}
public AdaptionException(@NotNull String message) {
super(String.format(FORMAT, message));
}
}
}

View File

@@ -0,0 +1,72 @@
/*
* 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.adapter;
import net.william278.husksync.HuskSync;
import org.jetbrains.annotations.NotNull;
import java.nio.charset.StandardCharsets;
public class GsonAdapter implements DataAdapter {
private final HuskSync plugin;
public GsonAdapter(@NotNull HuskSync plugin) {
this.plugin = plugin;
}
@Override
public <A extends Adaptable> byte[] toBytes(@NotNull A data) throws AdaptionException {
return this.toJson(data).getBytes(StandardCharsets.UTF_8);
}
@NotNull
@Override
public <A extends Adaptable> String toJson(@NotNull A data) throws AdaptionException {
try {
return plugin.getGson().toJson(data);
} catch (Throwable e) {
throw new AdaptionException("Failed to adapt data to JSON via Gson", e);
}
}
@Override
@NotNull
public <A extends Adaptable> A fromBytes(@NotNull byte[] data, @NotNull Class<A> type) throws AdaptionException {
return this.fromJson(new String(data, StandardCharsets.UTF_8), type);
}
@NotNull
@Override
public String bytesToString(byte[] bytes) {
return new String(bytes, StandardCharsets.UTF_8);
}
@Override
@NotNull
public <A extends Adaptable> A fromJson(@NotNull String data, @NotNull Class<A> type) throws AdaptionException {
try {
return plugin.getGson().fromJson(data, type);
} catch (Throwable e) {
throw new AdaptionException("Failed to adapt data from JSON via Gson", e);
}
}
}

View File

@@ -0,0 +1,68 @@
/*
* 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.adapter;
import net.william278.husksync.HuskSync;
import org.jetbrains.annotations.NotNull;
import org.xerial.snappy.Snappy;
import java.io.IOException;
public class SnappyGsonAdapter extends GsonAdapter {
public SnappyGsonAdapter(@NotNull HuskSync plugin) {
super(plugin);
}
@NotNull
@Override
public <A extends Adaptable> byte[] toBytes(@NotNull A data) throws AdaptionException {
try {
return Snappy.compress(super.toBytes(data));
} catch (IOException e) {
throw new AdaptionException("Failed to compress data through Snappy", e);
}
}
@NotNull
@Override
public <A extends Adaptable> A fromBytes(@NotNull byte[] data, @NotNull Class<A> type) throws AdaptionException {
try {
return super.fromBytes(decompressBytes(data), type);
} catch (IOException e) {
throw new AdaptionException("Failed to decompress data through Snappy", e);
}
}
@Override
@NotNull
public String bytesToString(byte[] bytes) {
try {
return super.bytesToString(decompressBytes(bytes));
} catch (IOException e) {
throw new AdaptionException("Failed to decompress data through Snappy", e);
}
}
private byte[] decompressBytes(byte[] bytes) throws IOException {
return Snappy.uncompress(bytes);
}
}

View File

@@ -1,157 +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.api;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSaveCause;
import net.william278.husksync.data.UserData;
import net.william278.husksync.data.UserDataSnapshot;
import net.william278.husksync.player.OnlineUser;
import net.william278.husksync.player.User;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* The base implementation of the HuskSync API, containing cross-platform API calls.
* </p>
* This class should not be used directly, but rather through platform-specific extending API classes.
*/
@SuppressWarnings("unused")
public abstract class BaseHuskSyncAPI {
/**
* <b>(Internal use only)</b> - Instance of the implementing plugin.
*/
protected final HuskSync plugin;
protected BaseHuskSyncAPI(@NotNull HuskSync plugin) {
this.plugin = plugin;
}
/**
* Returns a {@link User} by the given player's account {@link UUID}, if they exist.
*
* @param uuid the unique id of the player to get the {@link User} instance for
* @return future returning the {@link User} instance for the given player's unique id if they exist, otherwise an empty {@link Optional}
* @apiNote The player does not have to be online
* @since 2.0
*/
public final CompletableFuture<Optional<User>> getUser(@NotNull UUID uuid) {
return plugin.getDatabase().getUser(uuid);
}
/**
* Returns a {@link User} by the given player's username (case-insensitive), if they exist.
*
* @param username the username of the {@link User} instance for
* @return future returning the {@link User} instance for the given player's username if they exist,
* otherwise an empty {@link Optional}
* @apiNote The player does not have to be online, though their username has to be the username
* they had when they last joined the server.
* @since 2.0
*/
public final CompletableFuture<Optional<User>> getUser(@NotNull String username) {
return plugin.getDatabase().getUserByName(username);
}
/**
* Returns a {@link User}'s current {@link UserData}
*
* @param user the {@link User} to get the {@link UserData} for
* @return future returning the {@link UserData} for the given {@link User} if they exist, otherwise an empty {@link Optional}
* @apiNote If the user is not online on the implementing bukkit server,
* the {@link UserData} returned will be their last database-saved UserData.
* </p>
* Because of this, if the user is online on another server on the network,
* then the {@link UserData} returned by this method will <i>not necessarily reflective of
* their current state</i>
* @since 2.0
*/
public final CompletableFuture<Optional<UserData>> getUserData(@NotNull User user) {
return CompletableFuture.supplyAsync(() -> {
if (user instanceof OnlineUser) {
return ((OnlineUser) user).getUserData(plugin).join();
} else {
return plugin.getDatabase().getCurrentUserData(user).join().map(UserDataSnapshot::userData);
}
});
}
/**
* Sets the {@link UserData} to the database for the given {@link User}.
* </p>
* If the user is online and on the same cluster, their data will be updated in game.
*
* @param user the {@link User} to set the {@link UserData} for
* @param userData the {@link UserData} to set for the given {@link User}
* @return future returning void when complete
* @since 2.0
*/
public final CompletableFuture<Void> setUserData(@NotNull User user, @NotNull UserData userData) {
return CompletableFuture.runAsync(() ->
plugin.getDatabase().setUserData(user, userData, DataSaveCause.API)
.thenRun(() -> plugin.getRedisManager().sendUserDataUpdate(user, userData).join()));
}
/**
* Saves the {@link UserData} of an {@link OnlineUser} to the database
*
* @param user the {@link OnlineUser} to save the {@link UserData} of
* @return future returning void when complete
* @since 2.0
*/
public final CompletableFuture<Void> saveUserData(@NotNull OnlineUser user) {
return CompletableFuture.runAsync(() -> user.getUserData(plugin)
.thenAccept(optionalUserData -> optionalUserData.ifPresent(
userData -> plugin.getDatabase().setUserData(user, userData, DataSaveCause.API).join())));
}
/**
* Returns the saved {@link UserDataSnapshot} records for the given {@link User}
*
* @param user the {@link User} to get the {@link UserDataSnapshot} for
* @return future returning a list {@link UserDataSnapshot} for the given {@link User} if they exist,
* otherwise an empty {@link Optional}
* @apiNote The length of the list of VersionedUserData will correspond to the configured
* {@code max_user_data_records} config option
* @since 2.0
*/
public final CompletableFuture<List<UserDataSnapshot>> getSavedUserData(@NotNull User user) {
return CompletableFuture.supplyAsync(() -> plugin.getDatabase().getUserData(user).join());
}
/**
* Returns the JSON string representation of the given {@link UserData}
*
* @param userData the {@link UserData} to get the JSON string representation of
* @param prettyPrint whether to pretty print the JSON string
* @return the JSON string representation of the given {@link UserData}
* @since 2.0
*/
@NotNull
public final String getUserDataJson(@NotNull UserData userData, boolean prettyPrint) {
return plugin.getDataAdapter().toJson(userData, prettyPrint);
}
}

View File

@@ -0,0 +1,458 @@
/*
* 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.api;
import net.william278.desertwell.util.ThrowingConsumer;
import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.Adaptable;
import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.data.Identifier;
import net.william278.husksync.data.Serializer;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* The base implementation of the HuskSync API, containing cross-platform API calls.
* </p>
* This class should not be used directly, but rather through platform-specific extending API classes.
*
* @since 2.0
*/
@SuppressWarnings("unused")
public abstract class HuskSyncAPI {
/**
* <b>(Internal use only)</b> - Instance of the implementing plugin.
*/
protected final HuskSync plugin;
/**
* <b>(Internal use only)</b> - Constructor, instantiating the base API class.
*/
@ApiStatus.Internal
protected HuskSyncAPI(@NotNull HuskSync plugin) {
this.plugin = plugin;
}
/**
* Get a {@link User} by their UUID
*
* @param uuid The UUID of the user to get
* @return A future containing the user, or an empty optional if the user doesn't exist
* @since 3.0
*/
@NotNull
public CompletableFuture<Optional<User>> getUser(@NotNull UUID uuid) {
return plugin.supplyAsync(() -> plugin.getDatabase().getUser(uuid));
}
/**
* Get a {@link User} by their username
*
* @param username The username of the user to get
* @return A future containing the user, or an empty optional if the user doesn't exist
* @since 3.0
*/
@NotNull
public CompletableFuture<Optional<User>> getUser(@NotNull String username) {
return plugin.supplyAsync(() -> plugin.getDatabase().getUserByName(username));
}
/**
* Create a new data snapshot of an {@link OnlineUser}'s data.
*
* @param user The user to create the snapshot of
* @return The snapshot of the user's data
* @since 3.0
*/
@NotNull
public DataSnapshot.Packed createSnapshot(@NotNull OnlineUser user) {
return snapshotBuilder().saveCause(DataSnapshot.SaveCause.API).buildAndPack();
}
/**
* Get a {@link User}'s current data, as a {@link DataSnapshot.Unpacked}
* <p>
* If the user is online, this will create a new snapshot of their data with the {@code API} data save cause.
* </p>
* If the user is offline, this will return the latest snapshot of their data if that exists
* (an empty optional will be returned otherwise).
*
* @param user The user to get the data of
* @return A future containing the user's current data, or an empty optional if the user has no data
* @since 3.0
*/
public CompletableFuture<Optional<DataSnapshot.Unpacked>> getCurrentData(@NotNull User user) {
return plugin.getRedisManager()
.getUserData(UUID.randomUUID(), user)
.thenApply(data -> data.or(() -> plugin.getDatabase().getLatestSnapshot(user)))
.thenApply(data -> data.map(snapshot -> snapshot.unpack(plugin)));
}
/**
* Set a user's current data.
* <p>
* This will update the user's data in the database (creating a new snapshot) and send a data update,
* updating the user if they are online.
*
* @param user The user to set the data of
* @param data The data to set
* @since 3.0
*/
public void setCurrentData(@NotNull User user, @NotNull DataSnapshot data) {
plugin.runAsync(() -> {
final DataSnapshot.Packed packed = data instanceof DataSnapshot.Unpacked unpacked
? unpacked.pack(plugin) : (DataSnapshot.Packed) data;
addSnapshot(user, packed);
plugin.getRedisManager().sendUserDataUpdate(user, packed);
});
}
/**
* Edit a user's current data.
* <p>
* This will update the user's data in the database (creating a new snapshot) and send a data update,
* updating the user if they are online.
*
* @param user The user to edit the data of
* @param editor The editor function
* @since 3.0
*/
public void editCurrentData(@NotNull User user, @NotNull ThrowingConsumer<DataSnapshot.Unpacked> editor) {
getCurrentData(user).thenAccept(optional -> optional.ifPresent(data -> {
editor.accept(data);
setCurrentData(user, data);
}));
}
/**
* Get a list of all saved data snapshots for a user
*
* @param user The user to get the data snapshots of
* @return The user's data snapshots
* @since 3.0
*/
public CompletableFuture<List<DataSnapshot.Unpacked>> getSnapshots(@NotNull User user) {
return plugin.supplyAsync(
() -> plugin.getDatabase().getAllSnapshots(user).stream()
.map(snapshot -> snapshot.unpack(plugin))
.toList()
);
}
/**
* Get a specific data snapshot for a user
*
* @param user The user to get the data snapshot of
* @param versionId The version ID of the snapshot to get
* @return The user's data snapshot, or an empty optional if the user has no data
* @see #getSnapshots(User)
* @since 3.0
*/
public CompletableFuture<List<DataSnapshot.Unpacked>> getSnapshot(@NotNull User user, @NotNull UUID versionId) {
return plugin.supplyAsync(
() -> plugin.getDatabase().getSnapshot(user, versionId).stream()
.map(snapshot -> snapshot.unpack(plugin))
.toList()
);
}
/**
* Edit a data snapshot for a user
*
* @param user The user to edit the snapshot of
* @param versionId The version ID of the snapshot to edit
* @param editor The editor function
* @since 3.0
*/
public void editSnapshot(@NotNull User user, @NotNull UUID versionId,
@NotNull ThrowingConsumer<DataSnapshot.Unpacked> editor) {
plugin.runAsync(() -> plugin.getDatabase().getSnapshot(user, versionId).ifPresent(snapshot -> {
final DataSnapshot.Unpacked unpacked = snapshot.unpack(plugin);
editor.accept(unpacked);
plugin.getDatabase().updateSnapshot(user, unpacked.pack(plugin));
}));
}
/**
* Get the latest data snapshot for a user that has been saved in the database.
* <p>
* Not to be confused with {@link #getCurrentData(User)}, which will return the current data of a user
* if they are online (this method will only return their latest <i>saved</i> snapshot).
* </p>
*
* @param user The user to get the latest data snapshot of
* @return The user's latest data snapshot, or an empty optional if the user has no data
* @since 3.0
*/
public CompletableFuture<Optional<DataSnapshot.Unpacked>> getLatestSnapshot(@NotNull User user) {
return plugin.supplyAsync(
() -> plugin.getDatabase().getLatestSnapshot(user).map(snapshot -> snapshot.unpack(plugin))
);
}
/**
* Edit the latest data snapshot for a user
*
* @param user The user to edit the latest snapshot of
* @param editor The editor function
* @since 3.0
*/
public void editLatestSnapshot(@NotNull User user, @NotNull ThrowingConsumer<DataSnapshot.Unpacked> editor) {
plugin.runAsync(() -> plugin.getDatabase().getLatestSnapshot(user).ifPresent(snapshot -> {
final DataSnapshot.Unpacked unpacked = snapshot.unpack(plugin);
editor.accept(unpacked);
plugin.getDatabase().updateSnapshot(user, unpacked.pack(plugin));
}));
}
/**
* Adds a data snapshot to the database
*
* @param user The user to save the data for
* @param snapshot The snapshot to save
* @since 3.0
*/
public void addSnapshot(@NotNull User user, @NotNull DataSnapshot snapshot) {
plugin.runAsync(() -> plugin.getDatabase().addSnapshot(
user, snapshot instanceof DataSnapshot.Unpacked unpacked
? unpacked.pack(plugin) : (DataSnapshot.Packed) snapshot
));
}
/**
* Update an <i>existing</i> data snapshot in the database.
* Not to be confused with {@link #addSnapshot(User, DataSnapshot)}, which will add a new snapshot if one
* snapshot doesn't exist.
*
* @param user The user to update the snapshot of
* @param snapshot The snapshot to update
* @since 3.0
*/
public void updateSnapshot(@NotNull User user, @NotNull DataSnapshot snapshot) {
plugin.runAsync(() -> plugin.getDatabase().updateSnapshot(
user, snapshot instanceof DataSnapshot.Unpacked unpacked
? unpacked.pack(plugin) : (DataSnapshot.Packed) snapshot
));
}
/**
* Pin a data snapshot, preventing it from being rotated
*
* @param user The user to pin the snapshot of
* @param snapshotVersion The version ID of the snapshot to pin
* @since 3.0
*/
public void pinSnapshot(@NotNull User user, @NotNull UUID snapshotVersion) {
plugin.runAsync(() -> plugin.getDatabase().pinSnapshot(user, snapshotVersion));
}
/**
* Unpin a data snapshot, allowing it to be rotated
*
* @param user The user to unpin the snapshot of
* @param snapshotVersion The version ID of the snapshot to unpin
* @since 3.0
*/
public void unpinSnapshot(@NotNull User user, @NotNull UUID snapshotVersion) {
plugin.runAsync(() -> plugin.getDatabase().unpinSnapshot(user, snapshotVersion));
}
/**
* Delete a data snapshot from the database
*
* @param user The user to delete the snapshot of
* @param versionId The version ID of the snapshot to delete
* @return A future which will complete with true if the snapshot was deleted, or false if it wasn't
* (e.g., if the snapshot didn't exist)
* @since 3.0
*/
public CompletableFuture<Boolean> deleteSnapshot(@NotNull User user, @NotNull UUID versionId) {
return plugin.supplyAsync(() -> plugin.getDatabase().deleteSnapshot(user, versionId));
}
/**
* Delete a data snapshot from the database
*
* @param user The user to delete the snapshot of
* @param snapshot The snapshot to delete
* @return A future which will complete with true if the snapshot was deleted, or false if it wasn't
* (e.g., if the snapshot hasn't been saved to the database yet)
* @since 3.0
*/
public CompletableFuture<Boolean> deleteSnapshot(@NotNull User user, @NotNull DataSnapshot snapshot) {
return deleteSnapshot(user, snapshot.getId());
}
/**
* Registers a new custom data type serializer.
* <p>
* This allows for custom {@link Data} types to be persisted in {@link DataSnapshot}s. To register
* a new data type, you must provide a {@link Serializer} for serializing and deserializing the data type
* and invoke this method.
* </p>
* You'll need to do this on every server you wish to sync data between. On servers where the registered
* data type is not present, the data will be ignored and snapshots created on that server will not
* contain the data.
*
* @param identifier The identifier of the data type to register.
* Create one using {@code Identifier.from(Key.of("your_plugin_name", "key"))}
* @param serializer An implementation of {@link Serializer} for serializing and deserializing the {@link Data}
* @param <T> A type extending {@link Data}; this will represent the data being held.
*/
public <T extends Data> void registerDataSerializer(@NotNull Identifier identifier,
@NotNull Serializer<T> serializer) {
plugin.registerSerializer(identifier, serializer);
}
/**
* Get a {@link DataSnapshot.Unpacked} from a {@link DataSnapshot.Packed}
*
* @param unpacked The unpacked snapshot
* @return The packed snapshot
* @since 3.0
*/
@NotNull
public DataSnapshot.Packed packSnapshot(@NotNull DataSnapshot.Unpacked unpacked) {
return unpacked.pack(plugin);
}
/**
* Get a {@link DataSnapshot.Unpacked} from a {@link DataSnapshot.Packed}
*
* @param packed The packed snapshot
* @return The unpacked snapshot
* @since 3.0
*/
@NotNull
public DataSnapshot.Unpacked unpackSnapshot(@NotNull DataSnapshot.Packed packed) {
return packed.unpack(plugin);
}
/**
* Unpack, edit, and repack a data snapshot.
* </p>
* This won't save the snapshot to the database; it'll just edit the data snapshot in place.
*
* @param packed The packed snapshot
* @param editor An editor function for editing the unpacked snapshot
* @return The edited packed snapshot
* @since 3.0
*/
@NotNull
public DataSnapshot.Packed editPackedSnapshot(@NotNull DataSnapshot.Packed packed,
@NotNull ThrowingConsumer<DataSnapshot.Unpacked> editor) {
final DataSnapshot.Unpacked unpacked = packed.unpack(plugin);
editor.accept(unpacked);
return unpacked.pack(plugin);
}
/**
* Get the estimated size of a {@link DataSnapshot} in bytes
*
* @param snapshot The snapshot to get the size of
* @return The size of the snapshot in bytes
* @since 3.0
*/
public int getSnapshotFileSize(@NotNull DataSnapshot snapshot) {
return (snapshot instanceof DataSnapshot.Packed packed)
? packed.getFileSize(plugin)
: ((DataSnapshot.Unpacked) snapshot).pack(plugin).getFileSize(plugin);
}
/**
* Get a builder for creating a new data snapshot
*
* @return The builder
* @since 3.0
*/
@NotNull
public DataSnapshot.Builder snapshotBuilder() {
return DataSnapshot.builder(plugin).saveCause(DataSnapshot.SaveCause.API);
}
/**
* Deserialize a JSON string to an {@link Adaptable}
*
* @param serialized The serialized JSON string
* @param type The type of the element
* @param <T> The type of the element
* @return The deserialized element
* @throws Serializer.DeserializationException If the element could not be deserialized
*/
@NotNull
public <T extends Adaptable> T deserializeData(@NotNull String serialized, Class<T> type)
throws Serializer.DeserializationException {
return plugin.getDataAdapter().fromJson(serialized, type);
}
/**
* Serialize an {@link Adaptable} to a JSON string
*
* @param element The element to serialize
* @param <T> The type of the element
* @return The serialized JSON string
* @throws Serializer.SerializationException If the element could not be serialized
*/
@NotNull
public <T extends Adaptable> String serializeData(@NotNull T element)
throws Serializer.SerializationException {
return plugin.getDataAdapter().toJson(element);
}
/**
* <b>(Internal use only)</b> - Get the plugin instance
*
* @return The plugin instance
*/
@ApiStatus.Internal
public HuskSync getPlugin() {
return plugin;
}
/**
* An exception indicating the plugin has been accessed before it has been registered.
*/
static final class NotRegisteredException extends IllegalStateException {
private static final String MESSAGE = """
Could not access the HuskSync API as it has not yet been registered. This could be because:
1) HuskSync has failed to enable successfully
2) Your plugin isn't set to load after HuskSync has
(Check if it set as a (soft)depend in plugin.yml or to load: BEFORE in paper-plugin.yml?)
3) You are attempting to access HuskSync on plugin construction/before your plugin has enabled.
4) You have shaded HuskSync into your plugin jar and need to fix your maven/gradle/build script
to only include HuskSync as a dependency and not as a shaded dependency.""";
NotRegisteredException() {
super(MESSAGE);
}
}
}

View File

@@ -0,0 +1,84 @@
/*
* 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.command;
import net.william278.husksync.HuskSync;
import net.william278.husksync.user.CommandUser;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public abstract class Command extends Node {
private final String usage;
private final Map<String, Boolean> additionalPermissions;
protected Command(@NotNull String name, @NotNull List<String> aliases, @NotNull String usage,
@NotNull HuskSync plugin) {
super(name, aliases, plugin);
this.usage = usage;
this.additionalPermissions = new HashMap<>();
}
@Override
public final void onExecuted(@NotNull CommandUser executor, @NotNull String[] args) {
if (!executor.hasPermission(getPermission())) {
plugin.getLocales().getLocale("error_no_permission")
.ifPresent(executor::sendMessage);
return;
}
plugin.runAsync(() -> this.execute(executor, args));
}
public abstract void execute(@NotNull CommandUser executor, @NotNull String[] args);
@NotNull
public final String getRawUsage() {
return usage;
}
@NotNull
public final String getUsage() {
return "/" + getName() + " " + getRawUsage();
}
public final void addAdditionalPermissions(@NotNull Map<String, Boolean> permissions) {
permissions.forEach((permission, value) -> this.additionalPermissions.put(getPermission(permission), value));
}
@NotNull
public final Map<String, Boolean> getAdditionalPermissions() {
return additionalPermissions;
}
@NotNull
public String getDescription() {
return plugin.getLocales().getRawLocale(getName() + "_command_description")
.orElse(getUsage());
}
@NotNull
public final HuskSync getPlugin() {
return plugin;
}
}

View File

@@ -1,77 +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.command;
import net.william278.husksync.HuskSync;
import net.william278.husksync.player.OnlineUser;
import org.jetbrains.annotations.NotNull;
/**
* Represents an abstract cross-platform representation for a plugin command
*/
public abstract class CommandBase {
/**
* The input string to match for this command
*/
public final String command;
/**
* The permission node required to use this command
*/
public final String permission;
/**
* Alias input strings for this command
*/
public final String[] aliases;
/**
* Instance of the implementing plugin
*/
public final HuskSync plugin;
public CommandBase(@NotNull String command, @NotNull Permission permission, @NotNull HuskSync implementor, String... aliases) {
this.command = command;
this.permission = permission.node;
this.plugin = implementor;
this.aliases = aliases;
}
/**
* Fires when the command is executed
*
* @param player {@link OnlineUser} executing the command
* @param args Command arguments
*/
public abstract void onExecute(@NotNull OnlineUser player, @NotNull String[] args);
/**
* Returns the localised description string of this command
*
* @return the command description
*/
public String getDescription() {
return plugin.getLocales().getRawLocale(command + "_command_description")
.orElse("A HuskSync command");
}
}

View File

@@ -21,112 +21,72 @@ package net.william278.husksync.command;
import de.themoep.minedown.adventure.MineDown;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSaveCause;
import net.william278.husksync.data.UserData;
import net.william278.husksync.data.UserDataBuilder;
import net.william278.husksync.data.UserDataSnapshot;
import net.william278.husksync.player.OnlineUser;
import net.william278.husksync.player.User;
import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.NotNull;
import java.text.SimpleDateFormat;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
import java.util.stream.Collectors;
public class EnderChestCommand extends CommandBase implements TabCompletable {
public class EnderChestCommand extends ItemsCommand {
public EnderChestCommand(@NotNull HuskSync implementor) {
super("enderchest", Permission.COMMAND_ENDER_CHEST, implementor, "echest", "openechest");
public EnderChestCommand(@NotNull HuskSync plugin) {
super(plugin, List.of("enderchest", "echest", "openechest"));
}
@Override
public void onExecute(@NotNull OnlineUser player, @NotNull String[] args) {
if (args.length == 0 || args.length > 2) {
plugin.getLocales().getLocale("error_invalid_syntax", "/enderchest <player>")
.ifPresent(player::sendMessage);
protected void showItems(@NotNull OnlineUser viewer, @NotNull DataSnapshot.Unpacked snapshot,
@NotNull User user, boolean allowEdit) {
final Optional<Data.Items.EnderChest> optionalEnderChest = snapshot.getEnderChest();
if (optionalEnderChest.isEmpty()) {
plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(viewer::sendMessage);
return;
}
plugin.getDatabase().getUserByName(args[0].toLowerCase(Locale.ENGLISH)).thenAccept(optionalUser ->
optionalUser.ifPresentOrElse(user -> {
if (args.length == 2) {
// View user data by specified UUID
try {
final UUID versionUuid = UUID.fromString(args[1]);
plugin.getDatabase().getUserData(user, versionUuid).thenAccept(data -> data.ifPresentOrElse(
userData -> showEnderChestMenu(player, userData, user, false),
() -> plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(player::sendMessage)));
} catch (IllegalArgumentException e) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/enderchest <player> [version_uuid]").ifPresent(player::sendMessage);
}
} else {
// View (and edit) the latest user data
plugin.getDatabase().getCurrentUserData(user).thenAccept(optionalData -> optionalData.ifPresentOrElse(
versionedUserData -> showEnderChestMenu(player, versionedUserData, user,
player.hasPermission(Permission.COMMAND_ENDER_CHEST_EDIT.node)),
() -> plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(player::sendMessage)));
// Display opening message
plugin.getLocales().getLocale("ender_chest_viewer_opened", user.getUsername(),
snapshot.getTimestamp().format(DateTimeFormatter.ofPattern("dd/MM/yyyy, HH:mm")))
.ifPresent(viewer::sendMessage);
// Show GUI
final Data.Items.EnderChest enderChest = optionalEnderChest.get();
viewer.showGui(
enderChest,
plugin.getLocales().getLocale("ender_chest_viewer_menu_title", user.getUsername())
.orElse(new MineDown(String.format("%s's Ender Chest", user.getUsername()))),
allowEdit,
enderChest.getSlotCount(),
(itemsOnClose) -> {
if (allowEdit && !enderChest.equals(itemsOnClose)) {
plugin.runAsync(() -> this.updateItems(viewer, itemsOnClose, user));
}
}, () -> plugin.getLocales().getLocale("error_invalid_player")
.ifPresent(player::sendMessage)));
}
);
}
private void showEnderChestMenu(@NotNull OnlineUser player, @NotNull UserDataSnapshot userDataSnapshot,
@NotNull User dataOwner, boolean allowEdit) {
CompletableFuture.runAsync(() -> {
final UserData data = userDataSnapshot.userData();
data.getEnderChest().ifPresent(itemData -> {
// Show message
plugin.getLocales().getLocale("ender_chest_viewer_opened", dataOwner.username,
new SimpleDateFormat("MMM dd yyyy, HH:mm:ss.sss")
.format(userDataSnapshot.versionTimestamp()))
.ifPresent(player::sendMessage);
// Creates a new snapshot with the updated enderChest
@SuppressWarnings("DuplicatedCode")
private void updateItems(@NotNull OnlineUser viewer, @NotNull Data.Items.Items items, @NotNull User user) {
final Optional<DataSnapshot.Packed> latestData = plugin.getDatabase().getLatestSnapshot(user);
if (latestData.isEmpty()) {
plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(viewer::sendMessage);
return;
}
// Show inventory menu
player.showMenu(itemData, allowEdit, 3, plugin.getLocales()
.getLocale("ender_chest_viewer_menu_title", dataOwner.username)
.orElse(new MineDown("Ender Chest Viewer")))
.exceptionally(throwable -> {
plugin.log(Level.WARNING, "Exception displaying inventory menu to " + player.username, throwable);
return Optional.empty();
})
.thenAccept(dataOnClose -> {
if (dataOnClose.isEmpty() || !allowEdit) {
return;
}
// Create the updated data
final UserDataBuilder builder = UserData.builder(plugin.getMinecraftVersion());
data.getStatus().ifPresent(builder::setStatus);
data.getAdvancements().ifPresent(builder::setAdvancements);
data.getLocation().ifPresent(builder::setLocation);
data.getPersistentDataContainer().ifPresent(builder::setPersistentDataContainer);
data.getStatistics().ifPresent(builder::setStatistics);
data.getPotionEffects().ifPresent(builder::setPotionEffects);
data.getInventory().ifPresent(builder::setInventory);
builder.setEnderChest(dataOnClose.get());
// Set the updated data
final UserData updatedUserData = builder.build();
plugin.getDatabase()
.setUserData(dataOwner, updatedUserData, DataSaveCause.INVENTORY_COMMAND)
.thenRun(() -> plugin.getRedisManager().sendUserDataUpdate(dataOwner, updatedUserData));
});
});
// Create and pack the snapshot with the updated enderChest
final DataSnapshot.Packed snapshot = latestData.get().copy();
snapshot.edit(plugin, (data) -> {
data.setSaveCause(DataSnapshot.SaveCause.ENDERCHEST_COMMAND);
data.setPinned(plugin.getSettings().doAutoPin(DataSnapshot.SaveCause.ENDERCHEST_COMMAND));
data.getEnderChest().ifPresent(enderChest -> enderChest.setContents(items));
});
}
@Override
public List<String> onTabComplete(@NotNull String[] args) {
return plugin.getOnlineUsers().stream().map(user -> user.username)
.filter(argument -> argument.startsWith(args.length >= 1 ? args[0] : ""))
.sorted().collect(Collectors.toList());
plugin.getDatabase().addSnapshot(user, snapshot);
plugin.getRedisManager().sendUserDataUpdate(user, snapshot);
}
}

View File

@@ -19,18 +19,11 @@
package net.william278.husksync.command;
import net.william278.husksync.user.CommandUser;
import org.jetbrains.annotations.NotNull;
/**
* Interface providing console execution of commands
*/
public interface ConsoleExecutable {
public interface Executable {
/**
* What to do when console executes a command
*
* @param args command argument strings
*/
void onConsoleExecute(@NotNull String[] args);
void onExecuted(@NotNull CommandUser executor, @NotNull String[] args);
}

View File

@@ -23,26 +23,39 @@ import de.themoep.minedown.adventure.MineDown;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.TextColor;
import net.william278.desertwell.about.AboutMenu;
import net.william278.desertwell.util.UpdateChecker;
import net.william278.husksync.HuskSync;
import net.william278.husksync.migrator.Migrator;
import net.william278.husksync.player.OnlineUser;
import net.william278.husksync.user.CommandUser;
import net.william278.husksync.user.OnlineUser;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.logging.Level;
import java.util.stream.Collectors;
public class HuskSyncCommand extends CommandBase implements TabCompletable, ConsoleExecutable {
public class HuskSyncCommand extends Command implements TabProvider {
private final String[] SUB_COMMANDS = {"update", "about", "reload", "migrate"};
private static final Map<String, Boolean> SUB_COMMANDS = Map.of(
"about", false,
"reload", true,
"migrate", true,
"update", true
);
private final UpdateChecker updateChecker;
private final AboutMenu aboutMenu;
public HuskSyncCommand(@NotNull HuskSync implementor) {
super("husksync", Permission.COMMAND_HUSKSYNC, implementor);
public HuskSyncCommand(@NotNull HuskSync plugin) {
super("husksync", List.of(), "[" + String.join("|", SUB_COMMANDS.keySet()) + "]", plugin);
addAdditionalPermissions(SUB_COMMANDS);
this.updateChecker = plugin.getUpdateChecker();
this.aboutMenu = AboutMenu.builder()
.title(Component.text("HuskSync"))
.description(Component.text("A modern, cross-server player data synchronization system"))
.version(implementor.getPluginVersion())
.version(plugin.getPluginVersion())
.credits("Author",
AboutMenu.Credit.of("William278").description("Click to visit website").url("https://william278.net"))
.credits("Contributors",
@@ -68,123 +81,104 @@ public class HuskSyncCommand extends CommandBase implements TabCompletable, Cons
}
@Override
public void onExecute(@NotNull OnlineUser player, @NotNull String[] args) {
if (args.length < 1) {
sendAboutMenu(player);
public void execute(@NotNull CommandUser executor, @NotNull String[] args) {
final String subCommand = parseStringArg(args, 0).orElse("about").toLowerCase(Locale.ENGLISH);
if (SUB_COMMANDS.containsKey(subCommand) && !executor.hasPermission(getPermission(subCommand))) {
plugin.getLocales().getLocale("error_no_permission")
.ifPresent(executor::sendMessage);
return;
}
switch (args[0].toLowerCase(Locale.ENGLISH)) {
case "update", "version" -> {
if (!player.hasPermission(Permission.COMMAND_HUSKSYNC_UPDATE.node)) {
plugin.getLocales().getLocale("error_no_permission").ifPresent(player::sendMessage);
return;
}
plugin.getLatestVersionIfOutdated().thenAccept(newestVersion ->
newestVersion.ifPresentOrElse(
newVersion -> player.sendMessage(
new MineDown("[HuskSync](#00fb9a bold) [| A new version of HuskSync is available!"
+ " (v" + newVersion + " (Running: v" + plugin.getPluginVersion() + ")](#00fb9a)")),
() -> player.sendMessage(
new MineDown("[HuskSync](#00fb9a bold) [| HuskSync is up-to-date."
+ " (Running: v" + plugin.getPluginVersion() + ")](#00fb9a)"))));
}
case "about", "info" -> sendAboutMenu(player);
case "reload" -> {
if (!player.hasPermission(Permission.COMMAND_HUSKSYNC_RELOAD.node)) {
plugin.getLocales().getLocale("error_no_permission").ifPresent(player::sendMessage);
return;
}
plugin.reload();
plugin.getLocales().getLocale("reload_complete").ifPresent(player::sendMessage);
}
case "migrate" ->
plugin.getLocales().getLocale("error_console_command_only").ifPresent(player::sendMessage);
default -> plugin.getLocales().getLocale("error_invalid_syntax",
"/husksync <update/about/reload>")
.ifPresent(player::sendMessage);
}
}
@Override
public void onConsoleExecute(@NotNull String[] args) {
if (args.length < 1) {
plugin.log(Level.INFO, "Console usage: \"husksync <update/about/reload/migrate>\"");
return;
}
switch (args[0].toLowerCase(Locale.ENGLISH)) {
case "update", "version" -> plugin.getLatestVersionIfOutdated().thenAccept(newestVersion ->
newestVersion.ifPresentOrElse(newVersion -> plugin.log(Level.WARNING,
"An update is available for HuskSync, v" + newVersion
+ " (Running v" + plugin.getPluginVersion() + ")"),
() -> plugin.log(Level.INFO,
"HuskSync is up to date" +
" (Running v" + plugin.getPluginVersion() + ")")));
case "about", "info" -> aboutMenu.toString().lines().forEach(line -> plugin.log(Level.INFO, line));
switch (subCommand) {
case "about" -> executor.sendMessage(aboutMenu.toComponent());
case "reload" -> {
plugin.reload();
plugin.log(Level.INFO, "Reloaded config & message files.");
try {
plugin.loadConfigs();
plugin.getLocales().getLocale("reload_complete").ifPresent(executor::sendMessage);
} catch (Throwable e) {
executor.sendMessage(new MineDown(
"[Error:](#ff3300) [Failed to reload the plugin. Check console for errors.](#ff7e5e)"
));
plugin.log(Level.SEVERE, "Failed to reload the plugin", e);
}
}
case "migrate" -> {
if (args.length < 2) {
plugin.log(Level.INFO,
"Please choose a migrator, then run \"husksync migrate <migrator>\"");
logMigratorsList();
if (executor instanceof OnlineUser) {
plugin.getLocales().getLocale("error_console_command_only")
.ifPresent(executor::sendMessage);
return;
}
final Optional<Migrator> selectedMigrator = plugin.getAvailableMigrators().stream().filter(availableMigrator ->
availableMigrator.getIdentifier().equalsIgnoreCase(args[1])).findFirst();
selectedMigrator.ifPresentOrElse(migrator -> {
if (args.length < 3) {
plugin.log(Level.INFO, migrator.getHelpMenu());
return;
}
switch (args[2]) {
case "start" -> migrator.start().thenAccept(succeeded -> {
if (succeeded) {
plugin.log(Level.INFO, "Migration completed successfully!");
} else {
plugin.log(Level.WARNING, "Migration failed!");
}
});
case "set" -> migrator.handleConfigurationCommand(Arrays.copyOfRange(args, 3, args.length));
default -> plugin.log(Level.INFO,
"Invalid syntax. Console usage: \"husksync migrate " + args[1] + " <start/set>");
}
}, () -> {
plugin.log(Level.INFO,
"Please specify a valid migrator.\n" +
"If a migrator is not available, please verify that you meet the prerequisites to use it.");
logMigratorsList();
});
this.handleMigrationCommand(args);
}
default -> plugin.log(Level.INFO,
"Invalid syntax. Console usage: \"husksync <update/about/reload/migrate>\"");
case "update" -> updateChecker.check().thenAccept(checked -> {
if (checked.isUpToDate()) {
plugin.getLocales().getLocale("up_to_date", plugin.getPluginVersion().toString())
.ifPresent(executor::sendMessage);
return;
}
plugin.getLocales().getLocale("update_available", checked.getLatestVersion().toString(),
plugin.getPluginVersion().toString()).ifPresent(executor::sendMessage);
});
default -> plugin.getLocales().getLocale("error_invalid_syntax", getUsage())
.ifPresent(executor::sendMessage);
}
}
private void logMigratorsList() {
plugin.log(Level.INFO,
"List of available migrators:\nMigrator ID / Migrator Name:\n" +
plugin.getAvailableMigrators().stream()
.map(migrator -> migrator.getIdentifier() + " - " + migrator.getName())
.collect(Collectors.joining("\n")));
}
@Override
public List<String> onTabComplete(@NotNull String[] args) {
if (args.length <= 1) {
return Arrays.stream(SUB_COMMANDS)
.filter(argument -> argument.startsWith(args.length == 1 ? args[0] : ""))
.sorted().collect(Collectors.toList());
}
return Collections.emptyList();
}
private void sendAboutMenu(@NotNull OnlineUser player) {
if (!player.hasPermission(Permission.COMMAND_HUSKSYNC_ABOUT.node)) {
plugin.getLocales().getLocale("error_no_permission").ifPresent(player::sendMessage);
// Handle a migration console command input
private void handleMigrationCommand(@NotNull String[] args) {
if (args.length < 2) {
plugin.log(Level.INFO,
"Please choose a migrator, then run \"husksync migrate <migrator>\"");
this.logMigratorList();
return;
}
player.sendMessage(aboutMenu.toComponent());
final Optional<Migrator> selectedMigrator = plugin.getAvailableMigrators().stream()
.filter(available -> available.getIdentifier().equalsIgnoreCase(args[1]))
.findFirst();
selectedMigrator.ifPresentOrElse(migrator -> {
if (args.length < 3) {
plugin.log(Level.INFO, migrator.getHelpMenu());
return;
}
switch (args[2]) {
case "start" -> migrator.start().thenAccept(succeeded -> {
if (succeeded) {
plugin.log(Level.INFO, "Migration completed successfully!");
} else {
plugin.log(Level.WARNING, "Migration failed!");
}
});
case "set" -> migrator.handleConfigurationCommand(Arrays.copyOfRange(args, 3, args.length));
default -> plugin.log(Level.INFO, String.format(
"Invalid syntax. Console usage: \"husksync migrate %s <start/set>", args[1]
));
}
}, () -> {
plugin.log(Level.INFO,
"Please specify a valid migrator.\n" +
"If a migrator is not available, please verify that you meet the prerequisites to use it.");
this.logMigratorList();
});
}
// Log the list of available migrators
private void logMigratorList() {
plugin.log(Level.INFO, String.format(
"List of available migrators:\nMigrator ID / Migrator Name:\n%s",
plugin.getAvailableMigrators().stream()
.map(migrator -> String.format("%s - %s", migrator.getIdentifier(), migrator.getName()))
.collect(Collectors.joining("\n"))
));
}
@Nullable
@Override
public List<String> suggest(@NotNull CommandUser user, @NotNull String[] args) {
return switch (args.length) {
case 0, 1 -> SUB_COMMANDS.keySet().stream().sorted().toList();
default -> null;
};
}
}

View File

@@ -21,111 +21,72 @@ package net.william278.husksync.command;
import de.themoep.minedown.adventure.MineDown;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSaveCause;
import net.william278.husksync.data.UserData;
import net.william278.husksync.data.UserDataBuilder;
import net.william278.husksync.data.UserDataSnapshot;
import net.william278.husksync.player.OnlineUser;
import net.william278.husksync.player.User;
import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.NotNull;
import java.text.SimpleDateFormat;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
import java.util.stream.Collectors;
public class InventoryCommand extends CommandBase implements TabCompletable {
public class InventoryCommand extends ItemsCommand {
public InventoryCommand(@NotNull HuskSync implementor) {
super("inventory", Permission.COMMAND_INVENTORY, implementor, "invsee", "openinv");
public InventoryCommand(@NotNull HuskSync plugin) {
super(plugin, List.of("inventory", "invsee", "openinv"));
}
@Override
public void onExecute(@NotNull OnlineUser player, @NotNull String[] args) {
if (args.length == 0 || args.length > 2) {
plugin.getLocales().getLocale("error_invalid_syntax", "/inventory <player>")
.ifPresent(player::sendMessage);
protected void showItems(@NotNull OnlineUser viewer, @NotNull DataSnapshot.Unpacked snapshot,
@NotNull User user, boolean allowEdit) {
final Optional<Data.Items.Inventory> optionalInventory = snapshot.getInventory();
if (optionalInventory.isEmpty()) {
plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(viewer::sendMessage);
return;
}
plugin.getDatabase().getUserByName(args[0].toLowerCase(Locale.ENGLISH)).thenAccept(optionalUser ->
optionalUser.ifPresentOrElse(user -> {
if (args.length == 2) {
// View user data by specified UUID
try {
final UUID versionUuid = UUID.fromString(args[1]);
plugin.getDatabase().getUserData(user, versionUuid).thenAccept(data -> data.ifPresentOrElse(
userData -> showInventoryMenu(player, userData, user, false),
() -> plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(player::sendMessage)));
} catch (IllegalArgumentException e) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/inventory <player> [version_uuid]").ifPresent(player::sendMessage);
}
} else {
// View (and edit) the latest user data
plugin.getDatabase().getCurrentUserData(user).thenAccept(optionalData -> optionalData.ifPresentOrElse(
versionedUserData -> showInventoryMenu(player, versionedUserData, user,
player.hasPermission(Permission.COMMAND_INVENTORY_EDIT.node)),
() -> plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(player::sendMessage)));
// Display opening message
plugin.getLocales().getLocale("inventory_viewer_opened", user.getUsername(),
snapshot.getTimestamp().format(DateTimeFormatter.ofPattern("dd/MM/yyyy, HH:mm")))
.ifPresent(viewer::sendMessage);
// Show GUI
final Data.Items.Inventory inventory = optionalInventory.get();
viewer.showGui(
inventory,
plugin.getLocales().getLocale("inventory_viewer_menu_title", user.getUsername())
.orElse(new MineDown(String.format("%s's Inventory", user.getUsername()))),
allowEdit,
inventory.getSlotCount(),
(itemsOnClose) -> {
if (allowEdit && !inventory.equals(itemsOnClose)) {
plugin.runAsync(() -> this.updateItems(viewer, itemsOnClose, user));
}
}, () -> plugin.getLocales().getLocale("error_invalid_player")
.ifPresent(player::sendMessage)));
}
);
}
private void showInventoryMenu(@NotNull OnlineUser player, @NotNull UserDataSnapshot userDataSnapshot,
@NotNull User dataOwner, boolean allowEdit) {
CompletableFuture.runAsync(() -> {
final UserData data = userDataSnapshot.userData();
data.getInventory().ifPresent(itemData -> {
// Show message
plugin.getLocales().getLocale("inventory_viewer_opened", dataOwner.username,
new SimpleDateFormat("MMM dd yyyy, HH:mm:ss.sss")
.format(userDataSnapshot.versionTimestamp()))
.ifPresent(player::sendMessage);
// Creates a new snapshot with the updated inventory
@SuppressWarnings("DuplicatedCode")
private void updateItems(@NotNull OnlineUser viewer, @NotNull Data.Items.Items items, @NotNull User user) {
final Optional<DataSnapshot.Packed> latestData = plugin.getDatabase().getLatestSnapshot(user);
if (latestData.isEmpty()) {
plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(viewer::sendMessage);
return;
}
// Show inventory menu
player.showMenu(itemData, allowEdit, 5, plugin.getLocales()
.getLocale("inventory_viewer_menu_title", dataOwner.username)
.orElse(new MineDown("Inventory Viewer")))
.exceptionally(throwable -> {
plugin.log(Level.WARNING, "Exception displaying inventory menu to " + player.username, throwable);
return Optional.empty();
})
.thenAccept(dataOnClose -> {
if (dataOnClose.isEmpty() || !allowEdit) {
return;
}
// Create the updated data
final UserDataBuilder builder = UserData.builder(plugin.getMinecraftVersion());
data.getStatus().ifPresent(builder::setStatus);
data.getAdvancements().ifPresent(builder::setAdvancements);
data.getLocation().ifPresent(builder::setLocation);
data.getPersistentDataContainer().ifPresent(builder::setPersistentDataContainer);
data.getStatistics().ifPresent(builder::setStatistics);
data.getPotionEffects().ifPresent(builder::setPotionEffects);
data.getEnderChest().ifPresent(builder::setEnderChest);
builder.setInventory(dataOnClose.get());
// Set the updated data
final UserData updatedUserData = builder.build();
plugin.getDatabase()
.setUserData(dataOwner, updatedUserData, DataSaveCause.INVENTORY_COMMAND)
.thenRun(() -> plugin.getRedisManager().sendUserDataUpdate(dataOwner, updatedUserData));
});
});
// Create and pack the snapshot with the updated inventory
final DataSnapshot.Packed snapshot = latestData.get().copy();
snapshot.edit(plugin, (data) -> {
data.setSaveCause(DataSnapshot.SaveCause.INVENTORY_COMMAND);
data.setPinned(plugin.getSettings().doAutoPin(DataSnapshot.SaveCause.INVENTORY_COMMAND));
data.getInventory().ifPresent(inventory -> inventory.setContents(items));
});
plugin.getDatabase().addSnapshot(user, snapshot);
plugin.getRedisManager().sendUserDataUpdate(user, snapshot);
}
@Override
public List<String> onTabComplete(@NotNull String[] args) {
return plugin.getOnlineUsers().stream().map(user -> user.username)
.filter(argument -> argument.startsWith(args.length >= 1 ? args[0] : ""))
.sorted().collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,108 @@
/*
* 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.command;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.CommandUser;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
public abstract class ItemsCommand extends Command implements TabProvider {
protected ItemsCommand(@NotNull HuskSync plugin, @NotNull List<String> aliases) {
super(aliases.get(0), aliases.subList(1, aliases.size()), "<player> [version_uuid]", plugin);
setOperatorCommand(true);
addAdditionalPermissions(Map.of("edit", true));
}
@Override
public void execute(@NotNull CommandUser executor, @NotNull String[] args) {
if (!(executor instanceof OnlineUser player)) {
plugin.getLocales().getLocale("error_in_game_command_only")
.ifPresent(executor::sendMessage);
return;
}
// Find the user to view the items for
final Optional<User> optionalUser = parseStringArg(args, 0)
.flatMap(name -> plugin.getDatabase().getUserByName(name));
if (optionalUser.isEmpty()) {
plugin.getLocales().getLocale(
args.length >= 1 ? "error_invalid_player" : "error_invalid_syntax", getUsage()
).ifPresent(player::sendMessage);
return;
}
// Show the user data
final User user = optionalUser.get();
parseUUIDArg(args, 1).ifPresentOrElse(
version -> this.showSnapshotItems(player, user, version),
() -> this.showLatestItems(player, user)
);
}
// View (and edit) the latest user data
private void showLatestItems(@NotNull OnlineUser viewer, @NotNull User user) {
plugin.getRedisManager().getUserData(user.getUuid(), user).thenAccept(data -> data
.or(() -> plugin.getDatabase().getLatestSnapshot(user))
.ifPresentOrElse(
snapshot -> this.showItems(
viewer, snapshot.unpack(plugin), user,
viewer.hasPermission(getPermission("edit"))
),
() -> plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(viewer::sendMessage)
));
}
// View a specific version of the user data
private void showSnapshotItems(@NotNull OnlineUser viewer, @NotNull User user, @NotNull UUID version) {
plugin.getDatabase().getSnapshot(user, version)
.ifPresentOrElse(
snapshot -> this.showItems(
viewer, snapshot.unpack(plugin), user, false
),
() -> plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(viewer::sendMessage)
);
}
// Show a GUI menu with the correct item data from the snapshot
protected abstract void showItems(@NotNull OnlineUser viewer, @NotNull DataSnapshot.Unpacked snapshot,
@NotNull User user, boolean allowEdit);
@Nullable
@Override
public List<String> suggest(@NotNull CommandUser executor, @NotNull String[] args) {
return switch (args.length) {
case 0, 1 -> plugin.getOnlineUsers().stream().map(User::getUsername).toList();
default -> null;
};
}
}

View File

@@ -0,0 +1,105 @@
/*
* 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.command;
import net.william278.husksync.HuskSync;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Optional;
import java.util.StringJoiner;
import java.util.UUID;
public abstract class Node implements Executable {
protected static final String PERMISSION_PREFIX = "husksync.command";
protected final HuskSync plugin;
private final String name;
private final List<String> aliases;
private boolean operatorCommand = false;
protected Node(@NotNull String name, @NotNull List<String> aliases, @NotNull HuskSync plugin) {
if (name.isBlank()) {
throw new IllegalArgumentException("Command name cannot be blank");
}
this.name = name;
this.aliases = aliases;
this.plugin = plugin;
}
@NotNull
public String getName() {
return name;
}
@NotNull
public List<String> getAliases() {
return aliases;
}
@NotNull
public String getPermission(@NotNull String... child) {
final StringJoiner joiner = new StringJoiner(".")
.add(PERMISSION_PREFIX)
.add(getName());
for (final String node : child) {
joiner.add(node);
}
return joiner.toString().trim();
}
public boolean isOperatorCommand() {
return operatorCommand;
}
public void setOperatorCommand(boolean operatorCommand) {
this.operatorCommand = operatorCommand;
}
protected Optional<String> parseStringArg(@NotNull String[] args, int index) {
if (args.length > index) {
return Optional.of(args[index]);
}
return Optional.empty();
}
protected Optional<Integer> parseIntArg(@NotNull String[] args, int index) {
return parseStringArg(args, index).flatMap(arg -> {
try {
return Optional.of(Integer.parseInt(arg));
} catch (NumberFormatException e) {
return Optional.empty();
}
});
}
protected Optional<UUID> parseUUIDArg(@NotNull String[] args, int index) {
return parseStringArg(args, index).flatMap(arg -> {
try {
return Optional.of(UUID.fromString(arg));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
});
}
}

View File

@@ -1,120 +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.command;
import org.jetbrains.annotations.NotNull;
/**
* Static plugin permission nodes required to execute commands
*/
public enum Permission {
/*
* /husksync command permissions
*/
/**
* Lets the user use the {@code /husksync} command (subcommand permissions required)
*/
COMMAND_HUSKSYNC("husksync.command.husksync", DefaultAccess.EVERYONE),
/**
* Lets the user view plugin info {@code /husksync info}
*/
COMMAND_HUSKSYNC_ABOUT("husksync.command.husksync.info", DefaultAccess.EVERYONE),
/**
* Lets the user reload the plugin {@code /husksync reload}
*/
COMMAND_HUSKSYNC_RELOAD("husksync.command.husksync.reload", DefaultAccess.OPERATORS),
/**
* Lets the user view the plugin version and check for updates {@code /husksync update}
*/
COMMAND_HUSKSYNC_UPDATE("husksync.command.husksync.update", DefaultAccess.OPERATORS),
/*
* /userdata command permissions
*/
/**
* Lets the user view user data {@code /userdata view/list (player) (version_uuid)}
*/
COMMAND_USER_DATA("husksync.command.userdata", DefaultAccess.OPERATORS),
/**
* Lets the user restore and delete user data {@code /userdata restore/delete (player) (version_uuid)}
*/
COMMAND_USER_DATA_MANAGE("husksync.command.userdata.manage", DefaultAccess.OPERATORS),
/**
* Lets the user dump user data to a file or the web {@code /userdata dump (player) (version_uuid)}
*/
COMMAND_USER_DATA_DUMP("husksync.command.userdata.dump", DefaultAccess.NOBODY),
/*
* /inventory command permissions
*/
/**
* Lets the user use the {@code /inventory (player)} command and view offline players' inventories
*/
COMMAND_INVENTORY("husksync.command.inventory", DefaultAccess.OPERATORS),
/**
* Lets the user edit the contents of offline players' inventories
*/
COMMAND_INVENTORY_EDIT("husksync.command.inventory.edit", DefaultAccess.OPERATORS),
/*
* /enderchest command permissions
*/
/**
* Lets the user use the {@code /enderchest (player)} command and view offline players' ender chests
*/
COMMAND_ENDER_CHEST("husksync.command.enderchest", DefaultAccess.OPERATORS),
/**
* Lets the user edit the contents of offline players' ender chests
*/
COMMAND_ENDER_CHEST_EDIT("husksync.command.enderchest.edit", DefaultAccess.OPERATORS);
public final String node;
public final DefaultAccess defaultAccess;
Permission(@NotNull String node, @NotNull DefaultAccess defaultAccess) {
this.node = node;
this.defaultAccess = defaultAccess;
}
/**
* Identifies who gets what permissions by default
*/
public enum DefaultAccess {
/**
* Everyone gets this permission node by default
*/
EVERYONE,
/**
* Nobody gets this permission node by default
*/
NOBODY,
/**
* Server operators ({@code /op}) get this permission node by default
*/
OPERATORS
}
}

View File

@@ -1,39 +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.command;
import org.jetbrains.annotations.NotNull;
import java.util.List;
/**
* Interface providing tab completions for a command
*/
public interface TabCompletable {
/**
* What should be returned when the player or console attempts to TAB-complete a command
*
* @param args Current command arguments
* @return List of String arguments to offer TAB suggestions
*/
List<String> onTabComplete(@NotNull String[] args);
}

View File

@@ -0,0 +1,50 @@
/*
* 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.command;
import net.william278.husksync.user.CommandUser;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
public interface TabProvider {
@Nullable
List<String> suggest(@NotNull CommandUser user, @NotNull String[] args);
@NotNull
default List<String> getSuggestions(@NotNull CommandUser user, @NotNull String[] args) {
List<String> suggestions = suggest(user, args);
if (suggestions == null) {
suggestions = List.of();
}
return filter(suggestions, args);
}
@NotNull
default List<String> filter(@NotNull List<String> suggestions, @NotNull String[] args) {
return suggestions.stream()
.filter(suggestion -> args.length == 0 || suggestion.toLowerCase()
.startsWith(args[args.length - 1].toLowerCase().trim()))
.toList();
}
}

View File

@@ -20,309 +20,216 @@
package net.william278.husksync.command;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSaveCause;
import net.william278.husksync.data.UserData;
import net.william278.husksync.player.OnlineUser;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.CommandUser;
import net.william278.husksync.user.User;
import net.william278.husksync.util.DataDumper;
import net.william278.husksync.util.DataSnapshotList;
import net.william278.husksync.util.DataSnapshotOverview;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
import java.util.stream.Collectors;
public class UserDataCommand extends CommandBase implements TabCompletable {
public class UserDataCommand extends Command implements TabProvider {
private final String[] COMMAND_ARGUMENTS = {"view", "list", "delete", "restore", "pin", "dump"};
private static final Map<String, Boolean> SUB_COMMANDS = Map.of(
"view", false,
"list", false,
"delete", true,
"restore", true,
"pin", true,
"dump", true
);
public UserDataCommand(@NotNull HuskSync implementor) {
super("userdata", Permission.COMMAND_USER_DATA, implementor, "playerdata");
public UserDataCommand(@NotNull HuskSync plugin) {
super("userdata", List.of("playerdata"), String.format(
"<%s> [username] [version_uuid]", String.join("/", SUB_COMMANDS.keySet())
), plugin);
setOperatorCommand(true);
addAdditionalPermissions(SUB_COMMANDS);
}
@Override
public void onExecute(@NotNull OnlineUser player, @NotNull String[] args) {
if (args.length < 1) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata <view/list/delete/restore/pin/dump> <username> [version_uuid]")
.ifPresent(player::sendMessage);
public void execute(@NotNull CommandUser executor, @NotNull String[] args) {
final String subCommand = parseStringArg(args, 0).orElse("view").toLowerCase(Locale.ENGLISH);
final Optional<User> optionalUser = parseStringArg(args, 1)
.flatMap(name -> plugin.getDatabase().getUserByName(name))
.or(() -> parseStringArg(args, 0).flatMap(name -> plugin.getDatabase().getUserByName(name)))
.or(() -> args.length < 2 && executor instanceof User userExecutor
? Optional.of(userExecutor) : Optional.empty());
final Optional<UUID> optionalUuid = parseUUIDArg(args, 2).or(() -> parseUUIDArg(args, 1));
if (optionalUser.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_player")
.ifPresent(executor::sendMessage);
return;
}
switch (args[0].toLowerCase(Locale.ENGLISH)) {
case "view" -> {
if (args.length < 2) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata view <username> [version_uuid]")
.ifPresent(player::sendMessage);
return;
}
final String username = args[1];
if (args.length >= 3) {
try {
final UUID versionUuid = UUID.fromString(args[2]);
CompletableFuture.runAsync(() -> plugin.getDatabase()
.getUserByName(username.toLowerCase(Locale.ENGLISH))
.thenAccept(optionalUser -> optionalUser
.ifPresentOrElse(user -> plugin.getDatabase().getUserData(user, versionUuid)
.thenAccept(data -> data.ifPresentOrElse(
userData -> userData.displayDataOverview(player, user, plugin.getLocales()),
() -> plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(player::sendMessage))),
() -> plugin.getLocales().getLocale("error_invalid_player")
.ifPresent(player::sendMessage))));
} catch (IllegalArgumentException e) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata view <username> [version_uuid]")
.ifPresent(player::sendMessage);
}
} else {
CompletableFuture.runAsync(() -> plugin.getDatabase()
.getUserByName(username.toLowerCase(Locale.ENGLISH))
.thenAccept(optionalUser -> optionalUser
.ifPresentOrElse(user -> plugin.getDatabase().getCurrentUserData(user)
.thenAccept(latestData -> latestData.ifPresentOrElse(
userData -> userData.displayDataOverview(player, user, plugin.getLocales()),
() -> plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(player::sendMessage))),
() -> plugin.getLocales().getLocale("error_invalid_player")
.ifPresent(player::sendMessage))));
}
}
final User user = optionalUser.get();
switch (subCommand) {
case "view" -> optionalUuid.ifPresentOrElse(
// Show the specified snapshot
version -> plugin.getDatabase().getSnapshot(user, version).ifPresentOrElse(
data -> DataSnapshotOverview.of(
data.unpack(plugin), data.getFileSize(plugin), user, plugin
).show(executor),
() -> plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(executor::sendMessage)),
// Show the latest snapshot
() -> plugin.getDatabase().getLatestSnapshot(user).ifPresentOrElse(
data -> DataSnapshotOverview.of(
data.unpack(plugin), data.getFileSize(plugin), user, plugin
).show(executor),
() -> plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(executor::sendMessage))
);
case "list" -> {
if (!player.hasPermission(Permission.COMMAND_USER_DATA_MANAGE.node)) {
plugin.getLocales().getLocale("error_no_permission").ifPresent(player::sendMessage);
// Check if there is data to display
final List<DataSnapshot.Packed> dataList = plugin.getDatabase().getAllSnapshots(user);
if (dataList.isEmpty()) {
plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(executor::sendMessage);
return;
}
if (args.length < 2) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata list <username> [page]")
.ifPresent(player::sendMessage);
return;
}
final String username = args[1];
CompletableFuture.runAsync(() -> plugin.getDatabase()
.getUserByName(username.toLowerCase(Locale.ENGLISH))
.thenAccept(optionalUser -> optionalUser.ifPresentOrElse(
user -> plugin.getDatabase().getUserData(user).thenAccept(dataList -> {
// Check if there is data to display
if (dataList.isEmpty()) {
plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(player::sendMessage);
return;
}
// Determine page to display
int page = 1;
if (args.length >= 3) {
try {
page = Integer.parseInt(args[2]);
} catch (NumberFormatException e) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata list <username> [page]")
.ifPresent(player::sendMessage);
return;
}
}
// Show the list to the player
DataSnapshotList.create(dataList, user, plugin.getLocales())
.displayPage(player, page);
}),
() -> plugin.getLocales().getLocale("error_invalid_player")
.ifPresent(player::sendMessage))));
// Show the list to the player
DataSnapshotList.create(dataList, user, plugin).displayPage(
executor,
parseIntArg(args, 2).or(() -> parseIntArg(args, 1)).orElse(1)
);
}
case "delete" -> {
if (!player.hasPermission(Permission.COMMAND_USER_DATA_MANAGE.node)) {
plugin.getLocales().getLocale("error_no_permission").ifPresent(player::sendMessage);
if (optionalUuid.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata delete <username> <version_uuid>")
.ifPresent(executor::sendMessage);
return;
}
// Delete user data by specified UUID
if (args.length < 3) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata delete <username> <version_uuid>")
.ifPresent(player::sendMessage);
final UUID version = optionalUuid.get();
if (!plugin.getDatabase().deleteSnapshot(user, version)) {
plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(executor::sendMessage);
return;
}
final String username = args[1];
try {
final UUID versionUuid = UUID.fromString(args[2]);
CompletableFuture.runAsync(() -> plugin.getDatabase()
.getUserByName(username.toLowerCase(Locale.ENGLISH))
.thenAccept(optionalUser -> optionalUser.ifPresentOrElse(
user -> plugin.getDatabase().deleteUserData(user, versionUuid).thenAccept(deleted -> {
if (deleted) {
plugin.getLocales().getLocale("data_deleted",
versionUuid.toString().split("-")[0],
versionUuid.toString(),
user.username,
user.uuid.toString())
.ifPresent(player::sendMessage);
} else {
plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(player::sendMessage);
}
}),
() -> plugin.getLocales().getLocale("error_invalid_player")
.ifPresent(player::sendMessage))));
} catch (IllegalArgumentException e) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata delete <username> <version_uuid>")
.ifPresent(player::sendMessage);
}
plugin.getLocales().getLocale("data_deleted",
version.toString().split("-")[0],
version.toString(),
user.getUsername(),
user.getUuid().toString())
.ifPresent(executor::sendMessage);
}
case "restore" -> {
if (!player.hasPermission(Permission.COMMAND_USER_DATA_MANAGE.node)) {
plugin.getLocales().getLocale("error_no_permission").ifPresent(player::sendMessage);
if (optionalUuid.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata delete <username> <version_uuid>")
.ifPresent(executor::sendMessage);
return;
}
// Get user data by specified uuid and username
if (args.length < 3) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata restore <username> <version_uuid>")
.ifPresent(player::sendMessage);
// Restore user data by specified UUID
final Optional<DataSnapshot.Packed> optionalData = plugin.getDatabase().getSnapshot(user, optionalUuid.get());
if (optionalData.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(executor::sendMessage);
return;
}
final String username = args[1];
try {
final UUID versionUuid = UUID.fromString(args[2]);
CompletableFuture.runAsync(() -> plugin.getDatabase()
.getUserByName(username.toLowerCase(Locale.ENGLISH))
.thenAccept(optionalUser -> optionalUser.ifPresentOrElse(
user -> plugin.getDatabase().getUserData(user, versionUuid).thenAccept(data -> {
if (data.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(player::sendMessage);
return;
}
// Restore users with a minimum of one health (prevent restoring players with <=0 health)
final UserData userData = data.get().userData();
userData.getStatus().ifPresent(status -> status.health = Math.max(1, status.health));
// Restore users with a minimum of one health (prevent restoring players with <=0 health)
final DataSnapshot.Packed data = optionalData.get().copy();
data.edit(plugin, (unpacked -> {
unpacked.getHealth().ifPresent(status -> status.setHealth(Math.max(1, status.getHealth())));
unpacked.setSaveCause(DataSnapshot.SaveCause.BACKUP_RESTORE);
unpacked.setPinned(plugin.getSettings().doAutoPin(DataSnapshot.SaveCause.BACKUP_RESTORE));
}));
// Set the users data and send a message
plugin.getDatabase().setUserData(user, userData, DataSaveCause.BACKUP_RESTORE);
plugin.getRedisManager().sendUserDataUpdate(user, data.get().userData()).join();
plugin.getLocales().getLocale("data_restored",
user.username,
user.uuid.toString(),
versionUuid.toString().split("-")[0],
versionUuid.toString())
.ifPresent(player::sendMessage);
}),
() -> plugin.getLocales().getLocale("error_invalid_player")
.ifPresent(player::sendMessage))));
} catch (IllegalArgumentException e) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata restore <username> <version_uuid>")
.ifPresent(player::sendMessage);
}
// Set the user's data and send a message
plugin.getDatabase().addSnapshot(user, data);
plugin.getRedisManager().sendUserDataUpdate(user, data);
plugin.getLocales().getLocale("data_restored", user.getUsername(), user.getUuid().toString(),
data.getShortId(), data.getId().toString()).ifPresent(executor::sendMessage);
}
case "pin" -> {
if (!player.hasPermission(Permission.COMMAND_USER_DATA_MANAGE.node)) {
plugin.getLocales().getLocale("error_no_permission").ifPresent(player::sendMessage);
return;
}
if (args.length < 3) {
if (optionalUuid.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata pin <username> <version_uuid>")
.ifPresent(player::sendMessage);
.ifPresent(executor::sendMessage);
return;
}
final String username = args[1];
try {
final UUID versionUuid = UUID.fromString(args[2]);
CompletableFuture.runAsync(() -> plugin.getDatabase()
.getUserByName(username.toLowerCase(Locale.ENGLISH))
.thenAccept(optionalUser -> optionalUser.ifPresentOrElse(
user -> plugin.getDatabase().getUserData(user, versionUuid).thenAccept(
optionalUserData -> optionalUserData.ifPresentOrElse(userData -> {
if (userData.pinned()) {
plugin.getDatabase().unpinUserData(user, versionUuid).join();
plugin.getLocales().getLocale("data_unpinned",
versionUuid.toString().split("-")[0],
versionUuid.toString(),
user.username,
user.uuid.toString())
.ifPresent(player::sendMessage);
} else {
plugin.getDatabase().pinUserData(user, versionUuid).join();
plugin.getLocales().getLocale("data_pinned",
versionUuid.toString().split("-")[0],
versionUuid.toString(),
user.username,
user.uuid.toString())
.ifPresent(player::sendMessage);
}
}, () -> plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(player::sendMessage))),
() -> plugin.getLocales().getLocale("error_invalid_player")
.ifPresent(player::sendMessage))));
} catch (IllegalArgumentException e) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata pin <username> <version_uuid>")
.ifPresent(player::sendMessage);
// Check that the data exists
final Optional<DataSnapshot.Packed> optionalData = plugin.getDatabase().getSnapshot(user, optionalUuid.get());
if (optionalData.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(executor::sendMessage);
return;
}
// Pin or unpin the data
final DataSnapshot.Packed data = optionalData.get();
if (data.isPinned()) {
plugin.getDatabase().unpinSnapshot(user, data.getId());
} else {
plugin.getDatabase().pinSnapshot(user, data.getId());
}
plugin.getLocales().getLocale(data.isPinned() ? "data_unpinned" : "data_pinned", data.getShortId(),
data.getId().toString(), user.getUsername(), user.getUuid().toString())
.ifPresent(executor::sendMessage);
}
case "dump" -> {
if (!player.hasPermission(Permission.COMMAND_USER_DATA_DUMP.node)) {
plugin.getLocales().getLocale("error_no_permission").ifPresent(player::sendMessage);
return;
}
if (args.length < 3) {
if (optionalUuid.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata dump <username> <version_uuid>")
.ifPresent(player::sendMessage);
.ifPresent(executor::sendMessage);
return;
}
final boolean toWeb = args.length > 3 && args[3].equalsIgnoreCase("web");
final String username = args[1];
// Determine dump type
final boolean webDump = parseStringArg(args, 3)
.map(arg -> arg.equalsIgnoreCase("web"))
.orElse(false);
final Optional<DataSnapshot.Packed> data = plugin.getDatabase().getSnapshot(user, optionalUuid.get());
if (data.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(executor::sendMessage);
return;
}
// Dump the data
final DataSnapshot.Packed userData = data.get();
final DataDumper dumper = DataDumper.create(userData, user, plugin);
try {
final UUID versionUuid = UUID.fromString(args[2]);
CompletableFuture.runAsync(() -> plugin.getDatabase()
.getUserByName(username.toLowerCase(Locale.ENGLISH))
.thenAccept(optionalUser -> optionalUser.ifPresentOrElse(
user -> plugin.getDatabase().getUserData(user, versionUuid).thenAccept(
optionalUserData -> optionalUserData.ifPresentOrElse(userData -> {
try {
final DataDumper dumper = DataDumper.create(userData, user, plugin);
final String result = toWeb ? dumper.toWeb() : dumper.toFile();
plugin.getLocales().getLocale("data_dumped", versionUuid.toString()
.split("-")[0], user.username, result)
.ifPresent(player::sendMessage);
} catch (IOException e) {
plugin.log(Level.SEVERE, "Failed to dump user data", e);
}
}, () -> plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(player::sendMessage))),
() -> plugin.getLocales().getLocale("error_invalid_player")
.ifPresent(player::sendMessage))));
} catch (IllegalArgumentException e) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata dump <username> <version_uuid>")
.ifPresent(player::sendMessage);
plugin.getLocales().getLocale("data_dumped", userData.getShortId(), user.getUsername(),
(webDump ? dumper.toWeb() : dumper.toFile())).ifPresent(executor::sendMessage);
} catch (Throwable e) {
plugin.log(Level.SEVERE, "Failed to dump user data", e);
}
}
default -> plugin.getLocales().getLocale("error_invalid_syntax", getUsage())
.ifPresent(executor::sendMessage);
}
}
@Nullable
@Override
public List<String> onTabComplete(@NotNull String[] args) {
switch (args.length) {
case 0, 1 -> {
return Arrays.stream(COMMAND_ARGUMENTS)
.filter(argument -> argument.startsWith(args.length == 1 ? args[0] : ""))
.sorted().collect(Collectors.toList());
}
case 2 -> {
return plugin.getOnlineUsers().stream().map(user -> user.username)
.filter(argument -> argument.startsWith(args[1]))
.sorted().collect(Collectors.toList());
}
}
return Collections.emptyList();
public List<String> suggest(@NotNull CommandUser executor, @NotNull String[] args) {
return switch (args.length) {
case 0, 1 -> SUB_COMMANDS.keySet().stream().sorted().toList();
case 2 -> plugin.getOnlineUsers().stream().map(User::getUsername).toList();
case 4 -> parseStringArg(args, 0)
.map(arg -> arg.equalsIgnoreCase("dump") ? List.of("web", "file") : null)
.orElse(null);
default -> null;
};
}
}

View File

@@ -50,7 +50,7 @@ public class Locales {
public Map<String, String> rawLocales = new HashMap<>();
/**
* Returns a raw, un-formatted locale loaded from the locales file
* Returns a raw, unformatted locale loaded from the Locales file
*
* @param localeId String identifier of the locale, corresponding to a key in the file
* @return An {@link Optional} containing the locale corresponding to the id, if it exists
@@ -60,12 +60,12 @@ public class Locales {
}
/**
* Returns a raw, un-formatted locale loaded from the locales file, with replacements applied
* Returns a raw, unformatted locale loaded from the Locales file, with replacements applied
* <p>
* Note that replacements will not be MineDown-escaped; use {@link #escapeMineDown(String)} to escape replacements
*
* @param localeId String identifier of the locale, corresponding to a key in the file
* @param replacements Ordered array of replacement strings to fill in placeholders with
* @param replacements An ordered array of replacement strings to fill in placeholders with
* @return An {@link Optional} containing the replacement-applied locale corresponding to the id, if it exists
*/
public Optional<String> getRawLocale(@NotNull String localeId, @NotNull String... replacements) {
@@ -73,7 +73,7 @@ public class Locales {
}
/**
* Returns a MineDown-formatted locale from the locales file
* Returns a MineDown-formatted locale from the Locales file
*
* @param localeId String identifier of the locale, corresponding to a key in the file
* @return An {@link Optional} containing the formatted locale corresponding to the id, if it exists
@@ -83,12 +83,12 @@ public class Locales {
}
/**
* Returns a MineDown-formatted locale from the locales file, with replacements applied
* Returns a MineDown-formatted locale from the Locales file, with replacements applied
* <p>
* Note that replacements will be MineDown-escaped before application
*
* @param localeId String identifier of the locale, corresponding to a key in the file
* @param replacements Ordered array of replacement strings to fill in placeholders with
* @param replacements An ordered array of replacement strings to fill in placeholders with
* @return An {@link Optional} containing the replacement-applied, formatted locale corresponding to the id, if it exists
*/
public Optional<MineDown> getLocale(@NotNull String localeId, @NotNull String... replacements) {
@@ -100,7 +100,7 @@ public class Locales {
* Apply placeholder replacements to a raw locale
*
* @param rawLocale The raw, unparsed locale
* @param replacements Ordered array of replacement strings to fill in placeholders with
* @param replacements An ordered array of replacement strings to fill in placeholders with
* @return the raw locale, with inserted placeholders
*/
@NotNull
@@ -189,4 +189,25 @@ public class Locales {
public Locales() {
}
/**
* Determines the slot a system notification should be displayed in
*/
public enum NotificationSlot {
/**
* Displays the notification in the action bar
*/
ACTION_BAR,
/**
* Displays the notification in the chat
*/
CHAT,
/**
* Displays the notification in an Advancement Toast
*/
TOAST,
/**
* Does not display the notification
*/
NONE
}
}

View File

@@ -22,7 +22,10 @@ package net.william278.husksync.config;
import net.william278.annotaml.YamlComment;
import net.william278.annotaml.YamlFile;
import net.william278.annotaml.YamlKey;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.data.Identifier;
import net.william278.husksync.database.Database;
import net.william278.husksync.listener.EventListener;
import org.jetbrains.annotations.NotNull;
import java.util.*;
@@ -36,30 +39,43 @@ import java.util.*;
┃ Developed by William278 ┃
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
┣╸ Information: https://william278.net/project/husksync
Documentation: https://william278.net/docs/husksync""",
versionField = "config_version", versionNumber = 4)
Config Help: https://william278.net/docs/husksync/config-file/
┗╸ Documentation: https://william278.net/docs/husksync""")
public class Settings {
// Top-level settings
@YamlComment("Locale of the default language file to use. Docs: https://william278.net/docs/huskhomes/translations")
@YamlKey("language")
private String language = "en-gb";
@YamlComment("Whether to automatically check for plugin updates on startup")
@YamlKey("check_for_updates")
private boolean checkForUpdates = true;
@YamlComment("Specify a common ID for grouping servers running HuskSync. "
+ "Don't modify this unless you know what you're doing!")
@YamlKey("cluster_id")
private String clusterId = "";
@YamlComment("Enable development debug logging")
@YamlKey("debug_logging")
private boolean debugLogging = false;
@YamlComment("Whether to provide modern, rich TAB suggestions for commands (if available)")
@YamlKey("brigadier_tab_completion")
private boolean brigadierTabCompletion = false;
@YamlComment("Whether to enable the Player Analytics hook. Docs: https://william278.net/docs/husksync/plan-hook")
@YamlKey("enable_plan_hook")
private boolean enablePlanHook = true;
// Database settings
@YamlComment("Type of database to use (MYSQL, MARIADB)")
@YamlKey("database.type")
private Database.Type databaseType = Database.Type.MYSQL;
@YamlComment("Database connection settings")
@YamlComment("Specify credentials here for your MYSQL or MARIADB database")
@YamlKey("database.credentials.host")
private String mySqlHost = "localhost";
@@ -76,9 +92,13 @@ public class Settings {
private String mySqlPassword = "pa55w0rd";
@YamlKey("database.credentials.parameters")
private String mySqlConnectionParameters = "?autoReconnect=true&useSSL=false";
private String mySqlConnectionParameters = "?autoReconnect=true"
+ "&useSSL=false"
+ "&useUnicode=true"
+ "&characterEncoding=UTF-8";
@YamlComment("MySQL connection pool properties")
@YamlComment("MYSQL / MARIADB database Hikari connection pool properties. "
+ "Don't modify this unless you know what you're doing!")
@YamlKey("database.connection_pool.maximum_pool_size")
private int mySqlConnectionPoolSize = 10;
@@ -94,12 +114,13 @@ public class Settings {
@YamlKey("database.connection_pool.connection_timeout")
private long mySqlConnectionPoolTimeout = 5000;
@YamlComment("Names of tables to use on your database. Don't modify this unless you know what you're doing!")
@YamlKey("database.table_names")
private Map<String, String> tableNames = TableName.getDefaults();
// Redis settings
@YamlComment("Redis connection settings")
@YamlComment("Specify the credentials of your Redis database here. Set \"password\" to '' if you don't have one")
@YamlKey("redis.credentials.host")
private String redisHost = "localhost";
@@ -114,42 +135,75 @@ public class Settings {
// Synchronization settings
@YamlComment("Synchronization settings")
@YamlComment("The number of data snapshot backups that should be kept at once per user")
@YamlKey("synchronization.max_user_data_snapshots")
private int maxUserDataSnapshots = 5;
private int maxUserDataSnapshots = 16;
@YamlComment("Number of hours between new snapshots being saved as backups (Use \"0\" to backup all snapshots)")
@YamlKey("synchronization.snapshot_backup_frequency")
private int snapshotBackupFrequency = 4;
@YamlComment("List of save cause IDs for which a snapshot will be automatically pinned (so it won't be rotated)."
+ " Docs: https://william278.net/docs/husksync/data-rotation#save-causes")
@YamlKey("synchronization.auto_pinned_save_causes")
private List<String> autoPinnedSaveCauses = List.of(
DataSnapshot.SaveCause.INVENTORY_COMMAND.name(),
DataSnapshot.SaveCause.ENDERCHEST_COMMAND.name(),
DataSnapshot.SaveCause.BACKUP_RESTORE.name(),
DataSnapshot.SaveCause.CONVERTED_FROM_V2.name(),
DataSnapshot.SaveCause.LEGACY_MIGRATION.name(),
DataSnapshot.SaveCause.MPDB_MIGRATION.name()
);
@YamlComment("Whether to create a snapshot for users on a world when the server saves that world")
@YamlKey("synchronization.save_on_world_save")
private boolean saveOnWorldSave = true;
@YamlComment("Whether to create a snapshot for users when they die (containing their death drops)")
@YamlKey("synchronization.save_on_death")
private boolean saveOnDeath = false;
@YamlComment("Whether to save empty death drops for users when they die")
@YamlKey("synchronization.save_empty_drops_on_death")
private boolean saveEmptyDropsOnDeath = true;
@YamlComment("Whether to use the snappy data compression algorithm. Keep on unless you know what you're doing")
@YamlKey("synchronization.compress_data")
private boolean compressData = true;
@YamlComment("Where to display sync notifications (ACTION_BAR, CHAT, TOAST or NONE)")
@YamlKey("synchronization.notification_display_slot")
private NotificationDisplaySlot notificationDisplaySlot = NotificationDisplaySlot.ACTION_BAR;
private Locales.NotificationSlot notificationSlot = Locales.NotificationSlot.ACTION_BAR;
@YamlKey("synchronization.synchronise_dead_players_changing_server")
private boolean synchroniseDeadPlayersChangingServer = true;
@YamlComment("(Experimental) Persist Cartography Table locked maps to let them be viewed on any server")
@YamlKey("synchronization.persist_locked_maps")
private boolean persistLockedMaps = false;
@YamlComment("Whether dead players who log out and log in to a different server should have their items saved. "
+ "You may need to modify this if you're using the keepInventory gamerule.")
@YamlKey("synchronization.synchronize_dead_players_changing_server")
private boolean synchronizeDeadPlayersChangingServer = true;
@YamlComment("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).")
@YamlKey("synchronization.network_latency_milliseconds")
private int networkLatencyMilliseconds = 500;
@YamlComment("Which data types to synchronize (Docs: https://william278.net/docs/husksync/sync-features)")
@YamlKey("synchronization.features")
private Map<String, Boolean> synchronizationFeatures = SynchronizationFeature.getDefaults();
private Map<String, Boolean> synchronizationFeatures = Identifier.getConfigMap();
@YamlComment("Commands which should be blocked before a player has finished syncing (Use * to block all commands)")
@YamlKey("synchronization.blacklisted_commands_while_locked")
private List<String> blacklistedCommandsWhileLocked = new ArrayList<>(List.of("*"));
@YamlComment("Event priorities for listeners (HIGHEST, NORMAL, LOWEST). Change if you encounter plugin conflicts")
@YamlKey("synchronization.event_priorities")
private Map<String, String> synchronizationEventPriorities = EventType.getDefaults();
private Map<String, String> syncEventPriorities = EventListener.ListenerType.getDefaults();
// Zero-args constructor for instantiation via Annotaml
@SuppressWarnings("unused")
public Settings() {
}
@@ -172,6 +226,13 @@ public class Settings {
return debugLogging;
}
public boolean doBrigadierTabCompletion() {
return brigadierTabCompletion;
}
public boolean usePlanHook() {
return enablePlanHook;
}
@NotNull
public Database.Type getDatabaseType() {
@@ -246,7 +307,7 @@ public class Settings {
return redisPassword;
}
public boolean isRedisUseSsl() {
public boolean redisUseSsl() {
return redisUseSsl;
}
@@ -254,6 +315,10 @@ public class Settings {
return maxUserDataSnapshots;
}
public int getBackupFrequency() {
return snapshotBackupFrequency;
}
public boolean doSaveOnWorldSave() {
return saveOnWorldSave;
}
@@ -270,13 +335,21 @@ public class Settings {
return compressData;
}
@NotNull
public NotificationDisplaySlot getNotificationDisplaySlot() {
return notificationDisplaySlot;
public boolean doAutoPin(@NotNull DataSnapshot.SaveCause cause) {
return autoPinnedSaveCauses.contains(cause.name());
}
public boolean isSynchroniseDeadPlayersChangingServer() {
return synchroniseDeadPlayersChangingServer;
@NotNull
public Locales.NotificationSlot getNotificationDisplaySlot() {
return notificationSlot;
}
public boolean doPersistLockedMaps() {
return persistLockedMaps;
}
public boolean doSynchronizeDeadPlayersChangingServer() {
return synchronizeDeadPlayersChangingServer;
}
public int getNetworkLatencyMilliseconds() {
@@ -288,8 +361,8 @@ public class Settings {
return synchronizationFeatures;
}
public boolean getSynchronizationFeature(@NotNull SynchronizationFeature feature) {
return getSynchronizationFeatures().getOrDefault(feature.name().toLowerCase(Locale.ENGLISH), feature.enabledByDefault);
public boolean isSyncFeatureEnabled(@NotNull Identifier id) {
return id.isCustom() || getSynchronizationFeatures().getOrDefault(id.getKeyValue(), id.isEnabledByDefault());
}
@NotNull
@@ -298,12 +371,11 @@ public class Settings {
}
@NotNull
public EventPriority getEventPriority(@NotNull Settings.EventType eventType) {
public EventListener.Priority getEventPriority(@NotNull EventListener.ListenerType type) {
try {
return EventPriority.valueOf(synchronizationEventPriorities.get(eventType.name().toLowerCase(Locale.ENGLISH)));
return EventListener.Priority.valueOf(syncEventPriorities.get(type.name().toLowerCase(Locale.ENGLISH)));
} catch (IllegalArgumentException e) {
e.printStackTrace();
return EventPriority.NORMAL;
return EventListener.Priority.NORMAL;
}
}
@@ -334,111 +406,4 @@ public class Settings {
}
}
/**
* Determines the slot a system notification should be displayed in
*/
public enum NotificationDisplaySlot {
/**
* Displays the notification in the action bar
*/
ACTION_BAR,
/**
* Displays the notification in the chat
*/
CHAT,
/**
* Displays the notification in an advancement toast
*/
TOAST,
/**
* Does not display the notification
*/
NONE
}
/**
* Represents enabled synchronisation features
*/
public enum SynchronizationFeature {
INVENTORIES(true),
ENDER_CHESTS(true),
HEALTH(true),
MAX_HEALTH(true),
HUNGER(true),
EXPERIENCE(true),
POTION_EFFECTS(true),
ADVANCEMENTS(true),
GAME_MODE(true),
STATISTICS(true),
PERSISTENT_DATA_CONTAINER(false),
LOCKED_MAPS(false),
LOCATION(false);
private final boolean enabledByDefault;
SynchronizationFeature(boolean enabledByDefault) {
this.enabledByDefault = enabledByDefault;
}
@NotNull
private Map.Entry<String, Boolean> toEntry() {
return Map.entry(name().toLowerCase(Locale.ENGLISH), enabledByDefault);
}
@SuppressWarnings("unchecked")
@NotNull
private static Map<String, Boolean> getDefaults() {
return Map.ofEntries(Arrays.stream(values())
.map(SynchronizationFeature::toEntry)
.toArray(Map.Entry[]::new));
}
}
/**
* Represents events that HuskSync listens to, with a configurable priority listener
*/
public enum EventType {
JOIN_LISTENER(EventPriority.LOWEST),
QUIT_LISTENER(EventPriority.LOWEST),
DEATH_LISTENER(EventPriority.NORMAL);
private final EventPriority defaultPriority;
EventType(@NotNull EventPriority defaultPriority) {
this.defaultPriority = defaultPriority;
}
@NotNull
private Map.Entry<String, String> toEntry() {
return Map.entry(name().toLowerCase(Locale.ENGLISH), defaultPriority.name());
}
@SuppressWarnings("unchecked")
@NotNull
private static Map<String, String> getDefaults() {
return Map.ofEntries(Arrays.stream(values())
.map(EventType::toEntry)
.toArray(Map.Entry[]::new));
}
}
/**
* Represents priorities for events that HuskSync listens to
*/
public enum EventPriority {
/**
* Listens and processes the event execution last
*/
HIGHEST,
/**
* Listens in between {@link #HIGHEST} and {@link #LOWEST} priority marked
*/
NORMAL,
/**
* Listens and processes the event execution first
*/
LOWEST
}
}

View File

@@ -1,53 +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.data;
import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.NotNull;
import java.util.Date;
import java.util.Map;
/**
* A mapped piece of advancement data
*/
public class AdvancementData {
/**
* The advancement namespaced key
*/
@SerializedName("key")
public String key;
/**
* A map of completed advancement criteria to when it was completed
*/
@SerializedName("completed_criteria")
public Map<String, Date> completedCriteria;
public AdvancementData(@NotNull String key, @NotNull Map<String, Date> awardedCriteria) {
this.key = key;
this.completedCriteria = awardedCriteria;
}
@SuppressWarnings("unused")
protected AdvancementData() {
}
}

View File

@@ -1,46 +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.data;
import org.jetbrains.annotations.NotNull;
import org.xerial.snappy.Snappy;
import java.io.IOException;
public class CompressedDataAdapter extends JsonDataAdapter {
@Override
public byte[] toBytes(@NotNull UserData data) throws DataAdaptionException {
try {
return Snappy.compress(super.toBytes(data));
} catch (IOException e) {
throw new DataAdaptionException("Failed to compress data", e);
}
}
@Override
public @NotNull UserData fromBytes(byte[] data) throws DataAdaptionException {
try {
return super.fromBytes(Snappy.uncompress(data));
} catch (IOException e) {
throw new DataAdaptionException("Failed to decompress data", e);
}
}
}

View File

@@ -0,0 +1,368 @@
/*
* 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.data;
import com.google.gson.annotations.SerializedName;
import net.william278.husksync.HuskSync;
import net.william278.husksync.user.OnlineUser;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.stream.Collectors;
/**
* A piece of data, held by a {@link DataHolder}
*/
@SuppressWarnings("unused")
public interface Data {
/**
* Apply (set) this data container to the given {@link OnlineUser}
*
* @param user the user to apply this element to
* @param plugin the plugin instance
*/
void apply(@NotNull UserDataHolder user, @NotNull HuskSync plugin);
/**
* A data container holding data for:
* <ul>
* <li>Inventories</li>
* <li>Ender Chests</li>
* </ul>
*/
interface Items extends Data {
@NotNull
Stack[] getStack();
default int getSlotCount() {
return getStack().length;
}
record Stack(@NotNull String material, int amount, @Nullable String name,
@Nullable List<String> lore, @NotNull List<String> enchantments) {
}
default boolean isEmpty() {
return Arrays.stream(getStack()).allMatch(Objects::isNull) || getStack().length == 0;
}
void clear();
void setContents(@NotNull Items contents);
/**
* A data container holding data for inventories and selected hotbar slot
*/
interface Inventory extends Items {
int getHeldItemSlot();
void setHeldItemSlot(int heldItemSlot) throws IllegalArgumentException;
default Optional<Stack> getHelmet() {
return Optional.ofNullable(getStack()[39]);
}
default Optional<Stack> getChestplate() {
return Optional.ofNullable(getStack()[38]);
}
default Optional<Stack> getLeggings() {
return Optional.ofNullable(getStack()[37]);
}
default Optional<Stack> getBoots() {
return Optional.ofNullable(getStack()[36]);
}
default Optional<Stack> getOffHand() {
return Optional.ofNullable(getStack()[40]);
}
}
/**
* Data container holding data for ender chests
*/
interface EnderChest extends Items {
}
}
/**
* Data container holding data for potion effects
*/
interface PotionEffects extends Data {
@NotNull
List<Effect> getActiveEffects();
/**
* Represents a potion effect
*
* @param type the type of potion effect
* @param amplifier the amplifier of the potion effect
* @param duration the duration of the potion effect
* @param isAmbient whether the potion effect is ambient
* @param showParticles whether the potion effect shows particles
* @param hasIcon whether the potion effect displays a HUD icon
*/
record Effect(@SerializedName("type") @NotNull String type,
@SerializedName("amplifier") int amplifier,
@SerializedName("duration") int duration,
@SerializedName("is_ambient") boolean isAmbient,
@SerializedName("show_particles") boolean showParticles,
@SerializedName("has_icon") boolean hasIcon) {
}
}
/**
* Data container holding data for advancements
*/
interface Advancements extends Data {
@NotNull
List<Advancement> getCompleted();
@NotNull
default List<Advancement> getCompletedExcludingRecipes() {
return getCompleted().stream()
.filter(advancement -> !advancement.getKey().startsWith("minecraft:recipe"))
.collect(Collectors.toList());
}
void setCompleted(@NotNull List<Advancement> completed);
class Advancement {
@SerializedName("key")
private String key;
@SerializedName("completed_criteria")
private Map<String, Long> completedCriteria;
private Advancement(@NotNull String key, @NotNull Map<String, Date> completedCriteria) {
this.key = key;
this.completedCriteria = adaptDateMap(completedCriteria);
}
@SuppressWarnings("unused")
private Advancement() {
}
@NotNull
public static Advancement adapt(@NotNull String key, @NotNull Map<String, Date> completedCriteria) {
return new Advancement(key, completedCriteria);
}
@NotNull
private static Map<String, Long> adaptDateMap(@NotNull Map<String, Date> dateMap) {
return dateMap.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().getTime()));
}
@NotNull
private static Map<String, Date> adaptLongMap(@NotNull Map<String, Long> dateMap) {
return dateMap.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> new Date(e.getValue())));
}
@NotNull
public String getKey() {
return key;
}
public void setKey(@NotNull String key) {
this.key = key;
}
public Map<String, Date> getCompletedCriteria() {
return adaptLongMap(completedCriteria);
}
public void setCompletedCriteria(Map<String, Date> completedCriteria) {
this.completedCriteria = adaptDateMap(completedCriteria);
}
}
}
/**
* Data container holding data for the player's location
*/
interface Location extends Data {
double getX();
void setX(double x);
double getY();
void setY(double y);
double getZ();
void setZ(double z);
float getYaw();
void setYaw(float yaw);
float getPitch();
void setPitch(float pitch);
@NotNull
World getWorld();
void setWorld(@NotNull World world);
record World(
@SerializedName("name") @NotNull String name,
@SerializedName("uuid") @NotNull UUID uuid,
@SerializedName("environment") @NotNull String environment
) {
}
}
/**
* Data container holding data for statistics
*/
interface Statistics extends Data {
@NotNull
Map<String, Integer> getGenericStatistics();
@NotNull
Map<String, Map<String, Integer>> getBlockStatistics();
@NotNull
Map<String, Map<String, Integer>> getItemStatistics();
@NotNull
Map<String, Map<String, Integer>> getEntityStatistics();
}
/**
* Data container holding data for persistent data containers
*/
interface PersistentData extends Data {
}
/**
* A data container holding data for:
* <ul>
* <li>Health</li>
* <li>Max Health</li>
* <li>Health Scale</li>
* </ul>
*/
interface Health extends Data {
double getHealth();
void setHealth(double health);
double getMaxHealth();
void setMaxHealth(double maxHealth);
double getHealthScale();
void setHealthScale(double healthScale);
}
/**
* A data container holding data for:
* <ul>
*
* <li>Food Level</li>
* <li>Saturation</li>
* <li>Exhaustion</li>
* </ul>
*/
interface Hunger extends Data {
int getFoodLevel();
void setFoodLevel(int foodLevel);
float getSaturation();
void setSaturation(float saturation);
float getExhaustion();
void setExhaustion(float exhaustion);
}
/**
* A data container holding data for:
* <ul>
* <li>Total experience</li>
* <li>Experience level</li>
* <li>Experience progress</li>
* </ul>
*/
interface Experience extends Data {
int getTotalExperience();
void setTotalExperience(int totalExperience);
int getExpLevel();
void setExpLevel(int expLevel);
float getExpProgress();
void setExpProgress(float expProgress);
}
/**
* A data container holding data for:
* <ul>
* <li>Game mode</li>
* <li>Allow flight</li>
* <li>Is flying</li>
* </ul>
*/
interface GameMode extends Data {
@NotNull
String getGameMode();
void setGameMode(@NotNull String gameMode);
boolean getAllowFlight();
void setAllowFlight(boolean allowFlight);
boolean getIsFlying();
void setIsFlying(boolean isFlying);
}
}

View File

@@ -1,59 +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.data;
import org.jetbrains.annotations.NotNull;
/**
* An adapter that adapts {@link UserData} to and from a portable byte array.
*/
public interface DataAdapter {
/**
* Converts {@link UserData} to a byte array
*
* @param data The {@link UserData} to adapt
* @return The byte array.
* @throws DataAdaptionException If an error occurred during adaptation.
*/
byte[] toBytes(@NotNull UserData data) throws DataAdaptionException;
/**
* Serializes {@link UserData} to a JSON string.
*
* @param data The {@link UserData} to serialize
* @param pretty Whether to pretty print the JSON.
* @return The output json string.
* @throws DataAdaptionException If an error occurred during adaptation.
*/
@NotNull
String toJson(@NotNull UserData data, boolean pretty) throws DataAdaptionException;
/**
* Converts a byte array to {@link UserData}.
*
* @param data The byte array to adapt.
* @return The {@link UserData}.
* @throws DataAdaptionException If an error occurred during adaptation, such as if the byte array is invalid.
*/
@NotNull
UserData fromBytes(final byte[] data) throws DataAdaptionException;
}

View File

@@ -0,0 +1,140 @@
/*
* 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.data;
import org.jetbrains.annotations.NotNull;
import java.util.Map;
import java.util.Optional;
@SuppressWarnings("unused")
public interface DataHolder {
@NotNull
Map<Identifier, Data> getData();
default Optional<? extends Data> getData(@NotNull Identifier identifier) {
return Optional.ofNullable(getData().get(identifier));
}
default void setData(@NotNull Identifier identifier, @NotNull Data data) {
getData().put(identifier, data);
}
@NotNull
default Optional<Data.Items.Inventory> getInventory() {
return getData(Identifier.INVENTORY).map(Data.Items.Inventory.class::cast);
}
default void setInventory(@NotNull Data.Items.Inventory inventory) {
setData(Identifier.INVENTORY, inventory);
}
@NotNull
default Optional<Data.Items.EnderChest> getEnderChest() {
return getData(Identifier.ENDER_CHEST).map(Data.Items.EnderChest.class::cast);
}
default void setEnderChest(@NotNull Data.Items.EnderChest enderChest) {
setData(Identifier.ENDER_CHEST, enderChest);
}
@NotNull
default Optional<Data.PotionEffects> getPotionEffects() {
return getData(Identifier.POTION_EFFECTS).map(Data.PotionEffects.class::cast);
}
default void setPotionEffects(@NotNull Data.PotionEffects potionEffects) {
setData(Identifier.POTION_EFFECTS, potionEffects);
}
@NotNull
default Optional<Data.Advancements> getAdvancements() {
return getData(Identifier.ADVANCEMENTS).map(Data.Advancements.class::cast);
}
default void setAdvancements(@NotNull Data.Advancements advancements) {
setData(Identifier.ADVANCEMENTS, advancements);
}
@NotNull
default Optional<Data.Location> getLocation() {
return Optional.ofNullable((Data.Location) getData().get(Identifier.LOCATION));
}
default void setLocation(@NotNull Data.Location location) {
getData().put(Identifier.LOCATION, location);
}
@NotNull
default Optional<Data.Statistics> getStatistics() {
return Optional.ofNullable((Data.Statistics) getData().get(Identifier.STATISTICS));
}
default void setStatistics(@NotNull Data.Statistics statistics) {
getData().put(Identifier.STATISTICS, statistics);
}
@NotNull
default Optional<Data.Health> getHealth() {
return Optional.ofNullable((Data.Health) getData().get(Identifier.HEALTH));
}
default void setHealth(@NotNull Data.Health health) {
getData().put(Identifier.HEALTH, health);
}
@NotNull
default Optional<Data.Hunger> getHunger() {
return Optional.ofNullable((Data.Hunger) getData().get(Identifier.HUNGER));
}
default void setHunger(@NotNull Data.Hunger hunger) {
getData().put(Identifier.HUNGER, hunger);
}
@NotNull
default Optional<Data.Experience> getExperience() {
return Optional.ofNullable((Data.Experience) getData().get(Identifier.EXPERIENCE));
}
default void setExperience(@NotNull Data.Experience experience) {
getData().put(Identifier.EXPERIENCE, experience);
}
@NotNull
default Optional<Data.GameMode> getGameMode() {
return Optional.ofNullable((Data.GameMode) getData().get(Identifier.GAME_MODE));
}
default void setGameMode(@NotNull Data.GameMode gameMode) {
getData().put(Identifier.GAME_MODE, gameMode);
}
@NotNull
default Optional<Data.PersistentData> getPersistentData() {
return Optional.ofNullable((Data.PersistentData) getData().get(Identifier.PERSISTENT_DATA));
}
default void setPersistentData(@NotNull Data.PersistentData persistentData) {
getData().put(Identifier.PERSISTENT_DATA, persistentData);
}
}

View File

@@ -1,130 +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.data;
import net.william278.husksync.api.BaseHuskSyncAPI;
import net.william278.husksync.config.Locales;
import net.william278.husksync.player.OnlineUser;
import net.william278.husksync.player.User;
import org.jetbrains.annotations.NotNull;
import java.util.Locale;
/**
* Identifies the cause of a player data save.
*
* @implNote This enum is saved in the database.
* </p>
* Cause names have a max length of 32 characters.
*/
public enum DataSaveCause {
/**
* Indicates data saved when a player disconnected from the server (either to change servers, or to log off)
*
* @since 2.0
*/
DISCONNECT,
/**
* Indicates data saved when the world saved
*
* @since 2.0
*/
WORLD_SAVE,
/**
* Indicates data saved when the user died
*
* @since 2.1
*/
DEATH,
/**
* Indicates data saved when the server shut down
*
* @since 2.0
*/
SERVER_SHUTDOWN,
/**
* Indicates data was saved by editing inventory contents via the {@code /inventory} command
*
* @since 2.0
*/
INVENTORY_COMMAND,
/**
* Indicates data was saved by editing Ender Chest contents via the {@code /enderchest} command
*
* @since 2.0
*/
ENDERCHEST_COMMAND,
/**
* Indicates data was saved by restoring it from a previous version
*
* @since 2.0
*/
BACKUP_RESTORE,
/**
* Indicates data was saved by an API call
*
* @see BaseHuskSyncAPI#saveUserData(OnlineUser)
* @see BaseHuskSyncAPI#setUserData(User, UserData)
* @since 2.0
*/
API,
/**
* Indicates data was saved from being imported from MySQLPlayerDataBridge
*
* @since 2.0
*/
MPDB_MIGRATION,
/**
* Indicates data was saved from being imported from a legacy version (v1.x)
*
* @since 2.0
*/
LEGACY_MIGRATION,
/**
* Indicates data was saved by an unknown cause.
* </p>
* This should not be used and is only used for error handling purposes.
*
* @since 2.0
*/
UNKNOWN;
/**
* Returns a {@link DataSaveCause} by name.
*
* @return the {@link DataSaveCause} or {@link #UNKNOWN} if the name is not valid.
*/
@NotNull
public static DataSaveCause getCauseByName(@NotNull String name) {
for (DataSaveCause cause : values()) {
if (cause.name().equalsIgnoreCase(name)) {
return cause;
}
}
return UNKNOWN;
}
@NotNull
public String getDisplayName() {
return Locales.truncate(name().toLowerCase(Locale.ENGLISH), 10);
}
}

View File

@@ -0,0 +1,819 @@
/*
* 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.data;
import com.google.gson.annotations.Expose;
import com.google.gson.annotations.SerializedName;
import de.themoep.minedown.adventure.MineDown;
import net.william278.desertwell.util.Version;
import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.Adaptable;
import net.william278.husksync.adapter.DataAdapter;
import net.william278.husksync.config.Locales;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime;
import java.util.*;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* A snapshot of a {@link DataHolder} at a given time.
*
* @since 3.0
*/
public class DataSnapshot {
/*
* Current version of the snapshot data format.
* HuskSync v3.0 uses v4; HuskSync v2.0 uses v3. HuskSync v1.0 uses v1 or v2
*/
protected static final int CURRENT_FORMAT_VERSION = 4;
@SerializedName("id")
protected UUID id;
@SerializedName("pinned")
protected boolean pinned;
@SerializedName("timestamp")
protected OffsetDateTime timestamp;
@SerializedName("save_cause")
protected SaveCause saveCause;
@SerializedName("minecraft_version")
protected String minecraftVersion;
@SerializedName("platform_type")
protected String platformType;
@SerializedName("format_version")
protected int formatVersion;
@SerializedName("data")
protected Map<String, String> data;
private DataSnapshot(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp,
@NotNull SaveCause saveCause, @NotNull Map<String, String> data,
@NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) {
this.id = id;
this.pinned = pinned;
this.timestamp = timestamp;
this.saveCause = saveCause;
this.data = data;
this.minecraftVersion = minecraftVersion.toStringWithoutMetadata();
this.platformType = platformType;
this.formatVersion = formatVersion;
}
@SuppressWarnings("unused")
private DataSnapshot() {
}
@NotNull
@ApiStatus.Internal
public static DataSnapshot.Builder builder(@NotNull HuskSync plugin) {
return new Builder(plugin);
}
@NotNull
@ApiStatus.Internal
public static DataSnapshot.Packed deserialize(@NotNull HuskSync plugin, byte[] data) throws IllegalStateException {
final DataSnapshot.Packed snapshot = plugin.getDataAdapter().fromBytes(data, DataSnapshot.Packed.class);
if (snapshot.getMinecraftVersion().compareTo(plugin.getMinecraftVersion()) > 0) {
throw new IllegalStateException(String.format("Cannot set data for user because the Minecraft version of " +
"their user data (%s) is newer than the server's Minecraft version (%s)." +
"Please ensure each server is running the same version of Minecraft.",
snapshot.getMinecraftVersion(), plugin.getMinecraftVersion()));
}
if (snapshot.getFormatVersion() > CURRENT_FORMAT_VERSION) {
throw new IllegalStateException(String.format("Cannot set data for user because the format version of " +
"their user data (%s) is newer than the current format version (%s). " +
"Please ensure each server is running the latest version of HuskSync.",
snapshot.getFormatVersion(), CURRENT_FORMAT_VERSION));
}
if (snapshot.getFormatVersion() < CURRENT_FORMAT_VERSION) {
if (plugin.getLegacyConverter().isPresent()) {
return plugin.getLegacyConverter().get().convert(data);
}
throw new IllegalStateException(String.format(
"No legacy converter to convert format version: %s", snapshot.getFormatVersion()
));
}
if (!snapshot.getPlatformType().equalsIgnoreCase(plugin.getPlatformType())) {
throw new IllegalStateException(String.format("Cannot set data for user because the platform type of " +
"their user data (%s) is different to the server platform type (%s). " +
"Please ensure each server is running the same platform type.",
snapshot.getPlatformType(), plugin.getPlatformType()));
}
return snapshot;
}
/**
* Return the ID of the snapshot
*
* @return The snapshot ID
* @since 3.0
*/
@NotNull
public UUID getId() {
return id;
}
/**
* Get the short display ID of the snapshot
*
* @return The short display ID
* @since 3.0
*/
@NotNull
public String getShortId() {
return id.toString().substring(0, 8);
}
/**
* Get whether the snapshot is pinned
*
* @return Whether the snapshot is pinned
* @since 3.0
*/
public boolean isPinned() {
return pinned;
}
/**
* Set whether the snapshot is pinned
*
* @param pinned Whether the snapshot is pinned
* @since 3.0
*/
public void setPinned(boolean pinned) {
this.pinned = pinned;
}
/**
* Get why the snapshot was created
*
* @return The {@link SaveCause data save cause} of the snapshot
* @since 3.0
*/
@NotNull
public SaveCause getSaveCause() {
return saveCause;
}
/**
* Set why the snapshot was created
*
* @param saveCause The {@link SaveCause data save cause} of the snapshot
* @since 3.0
*/
public void setSaveCause(SaveCause saveCause) {
this.saveCause = saveCause;
}
/**
* Get when the snapshot was created
*
* @return The {@link OffsetDateTime timestamp} of the snapshot
* @since 3.0
*/
@NotNull
public OffsetDateTime getTimestamp() {
return timestamp;
}
/**
* Get the Minecraft version of the server when the Snapshot was created
*
* @return The Minecraft version of the server when the Snapshot was created
* @since 3.0
*/
@NotNull
public Version getMinecraftVersion() {
return Version.fromString(minecraftVersion);
}
/**
* Get the platform type of the server when the Snapshot was created
*
* @return The platform type of the server when the Snapshot was created (e.g. {@code "bukkit"})
* @since 3.0
*/
@NotNull
public String getPlatformType() {
return platformType;
}
/**
* Get the format version of the snapshot (indicating the version of HuskSync that created it)
* <ul>
* <li>1: HuskSync v1.0+</li>
* <li>2: HuskSync v1.5+</li>
* <li>3: HuskSync v2.0+</li>
* <li>4: HuskSync v3.0+</li>
* </ul>
*
* @return The format version of the snapshot
* @since 3.0
*/
public int getFormatVersion() {
return formatVersion;
}
/**
* A packed {@link DataSnapshot} that has not been deserialized.
*
* @since 3.0
*/
public static class Packed extends DataSnapshot implements Adaptable {
protected Packed(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp,
@NotNull SaveCause saveCause, @NotNull Map<String, String> data,
@NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) {
super(id, pinned, timestamp, saveCause, data, minecraftVersion, platformType, formatVersion);
}
@SuppressWarnings("unused")
private Packed() {
}
@ApiStatus.Internal
public void edit(@NotNull HuskSync plugin, @NotNull Consumer<Unpacked> editor) {
final Unpacked data = unpack(plugin);
editor.accept(data);
this.pinned = data.isPinned();
this.saveCause = data.getSaveCause();
this.data = data.serializeData(plugin);
}
/**
* Create a copy of this snapshot at the current system timestamp with a new ID
*
* @return The copied snapshot (with a new ID, with a timestamp of the current system time)
*/
@NotNull
public Packed copy() {
return new Packed(
UUID.randomUUID(), pinned, OffsetDateTime.now(), saveCause, data,
getMinecraftVersion(), platformType, formatVersion
);
}
@NotNull
@ApiStatus.Internal
public byte[] asBytes(@NotNull HuskSync plugin) throws DataAdapter.AdaptionException {
return plugin.getDataAdapter().toBytes(this);
}
@NotNull
@ApiStatus.Internal
public String asJson(@NotNull HuskSync plugin) throws DataAdapter.AdaptionException {
return plugin.getDataAdapter().toJson(this);
}
@ApiStatus.Internal
public int getFileSize(@NotNull HuskSync plugin) {
return asBytes(plugin).length;
}
@NotNull
public DataSnapshot.Unpacked unpack(@NotNull HuskSync plugin) {
return new Unpacked(
id, pinned, timestamp, saveCause, data,
getMinecraftVersion(), platformType, formatVersion, plugin
);
}
}
/**
* An unpacked {@link DataSnapshot}.
*
* @since 3.0
*/
public static class Unpacked extends DataSnapshot implements DataHolder {
@Expose(serialize = false, deserialize = false)
private final Map<Identifier, Data> deserialized;
private Unpacked(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp,
@NotNull SaveCause saveCause, @NotNull Map<String, String> data,
@NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion,
@NotNull HuskSync plugin) {
super(id, pinned, timestamp, saveCause, data, minecraftVersion, platformType, formatVersion);
this.deserialized = deserializeData(plugin);
}
private Unpacked(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp,
@NotNull SaveCause saveCause, @NotNull Map<Identifier, Data> data,
@NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) {
super(id, pinned, timestamp, saveCause, Map.of(), minecraftVersion, platformType, formatVersion);
this.deserialized = data;
}
@NotNull
@ApiStatus.Internal
private Map<Identifier, Data> deserializeData(@NotNull HuskSync plugin) {
return data.entrySet().stream()
.map((entry) -> plugin.getIdentifier(entry.getKey()).map(id -> Map.entry(
id, plugin.getSerializers().get(id).deserialize(entry.getValue())
)).orElse(null))
.filter(Objects::nonNull)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
@NotNull
@ApiStatus.Internal
private Map<String, String> serializeData(@NotNull HuskSync plugin) {
return deserialized.entrySet().stream()
.map((entry) -> Map.entry(entry.getKey().toString(),
Objects.requireNonNull(
plugin.getSerializers().get(entry.getKey()),
String.format("No serializer found for %s", entry.getKey())
).serialize(entry.getValue())))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
/**
* Get the data the snapshot is holding
*
* @return The data map
* @since 3.0
*/
@NotNull
public Map<Identifier, Data> getData() {
return deserialized;
}
/**
* Pack the {@link DataSnapshot} into a {@link DataSnapshot.Packed packed} snapshot
*
* @param plugin The HuskSync plugin instance
* @return The packed snapshot
* @since 3.0
*/
@NotNull
@ApiStatus.Internal
public DataSnapshot.Packed pack(@NotNull HuskSync plugin) {
return new DataSnapshot.Packed(
id, pinned, timestamp, saveCause, serializeData(plugin),
getMinecraftVersion(), platformType, formatVersion
);
}
}
/**
* A builder for {@link DataSnapshot}s.
*
* @since 3.0
*/
@SuppressWarnings("unused")
public static class Builder {
private final HuskSync plugin;
private SaveCause saveCause;
private boolean pinned;
private OffsetDateTime timestamp;
private final Map<Identifier, Data> data;
private Builder(@NotNull HuskSync plugin) {
this.plugin = plugin;
this.pinned = false;
this.data = new HashMap<>();
this.timestamp = OffsetDateTime.now();
}
/**
* Set the cause of the data save
*
* @param saveCause The cause of the data save
* @return The builder
* @apiNote If the {@link SaveCause data save cause} specified is configured to auto-pin, then the value of
* {@link #pinned(boolean)} will be ignored
* @since 3.0
*/
@NotNull
public Builder saveCause(@NotNull SaveCause saveCause) {
this.saveCause = saveCause;
return this;
}
/**
* Set whether the data should be pinned
*
* @param pinned Whether the data should be pinned
* @return The builder
* @apiNote If the {@link SaveCause data save cause} specified is configured to auto-pin, this will be ignored
* @since 3.0
*/
@NotNull
public Builder pinned(boolean pinned) {
this.pinned = pinned;
return this;
}
/**
* Set the timestamp of the snapshot.
* By default, this is the current server time.
* The timestamp passed to this method cannot be in the future.
* <p>
* Note that this will affect the rotation of data snapshots in the database if unpinned,
* as well as the order snapshots appear in the list.
*
* @param timestamp The timestamp
* @return The builder
* @throws IllegalArgumentException if the timestamp is in the future
* @since 3.0
*/
@NotNull
public Builder timestamp(@NotNull OffsetDateTime timestamp) {
if (timestamp.isAfter(OffsetDateTime.now())) {
throw new IllegalArgumentException("Data snapshots cannot have a timestamp set in the future");
}
this.timestamp = timestamp;
return this;
}
/**
* Set the data for a given identifier
*
* @param identifier The identifier
* @param data The data
* @return The builder
* @since 3.0
*/
@NotNull
public Builder data(@NotNull Identifier identifier, @NotNull Data data) {
this.data.put(identifier, data);
return this;
}
/**
* Set a map of data to the snapshot
*
* @param data The data
* @return The builder
* @since 3.0
*/
@NotNull
public Builder data(@NotNull Map<Identifier, Data> data) {
this.data.putAll(data);
return this;
}
/**
* Set the inventory contents of the snapshot
* <p>
* Equivalent to {@code data(Identifier.INVENTORY, inventory)}
* </p>
*
* @param inventory The inventory contents
* @return The builder
* @since 3.0
*/
@NotNull
public Builder inventory(@NotNull Data.Items.Inventory inventory) {
return data(Identifier.INVENTORY, inventory);
}
/**
* Set the Ender Chest contents of the snapshot
* <p>
* Equivalent to {@code data(Identifier.ENDER_CHEST, inventory)}
* </p>
*
* @param enderChest The Ender Chest contents
* @return The builder
* @since 3.0
*/
@NotNull
public Builder enderChest(@NotNull Data.Items.EnderChest enderChest) {
return data(Identifier.ENDER_CHEST, enderChest);
}
/**
* Set the potion effects of the snapshot
* <p>
* Equivalent to {@code data(Identifier.POTION_EFFECTS, potionEffects)}
* </p>
*
* @param potionEffects The potion effects
* @return The builder
* @since 3.0
*/
@NotNull
public Builder potionEffects(@NotNull Data.PotionEffects potionEffects) {
return data(Identifier.POTION_EFFECTS, potionEffects);
}
/**
* Set the advancements of the snapshot
* <p>
* Equivalent to {@code data(Identifier.ADVANCEMENTS, advancements)}
* </p>
*
* @param advancements The advancements
* @return The builder
* @since 3.0
*/
@NotNull
public Builder advancements(@NotNull Data.Advancements advancements) {
return data(Identifier.ADVANCEMENTS, advancements);
}
/**
* Set the location of the snapshot
* <p>
* Equivalent to {@code data(Identifier.LOCATION, location)}
* </p>
*
* @param location The location
* @return The builder
* @since 3.0
*/
@NotNull
public Builder location(@NotNull Data.Location location) {
return data(Identifier.LOCATION, location);
}
/**
* Set the statistics of the snapshot
* <p>
* Equivalent to {@code data(Identifier.STATISTICS, statistics)}
* </p>
*
* @param statistics The statistics
* @return The builder
* @since 3.0
*/
@NotNull
public Builder statistics(@NotNull Data.Statistics statistics) {
return data(Identifier.STATISTICS, statistics);
}
/**
* Set the health of the snapshot
* <p>
* Equivalent to {@code data(Identifier.HEALTH, health)}
* </p>
*
* @param health The health
* @return The builder
* @since 3.0
*/
@NotNull
public Builder health(@NotNull Data.Health health) {
return data(Identifier.HEALTH, health);
}
/**
* Set the hunger of the snapshot
* <p>
* Equivalent to {@code data(Identifier.HUNGER, hunger)}
* </p>
*
* @param hunger The hunger
* @return The builder
* @since 3.0
*/
@NotNull
public Builder hunger(@NotNull Data.Hunger hunger) {
return data(Identifier.HUNGER, hunger);
}
/**
* Set the experience of the snapshot
* <p>
* Equivalent to {@code data(Identifier.EXPERIENCE, experience)}
* </p>
*
* @param experience The experience
* @return The builder
* @since 3.0
*/
@NotNull
public Builder experience(@NotNull Data.Experience experience) {
return data(Identifier.EXPERIENCE, experience);
}
/**
* Set the game mode of the snapshot
* <p>
* Equivalent to {@code data(Identifier.GAME_MODE, gameMode)}
* </p>
*
* @param gameMode The game mode
* @return The builder
* @since 3.0
*/
@NotNull
public Builder gameMode(@NotNull Data.GameMode gameMode) {
return data(Identifier.GAME_MODE, gameMode);
}
/**
* Set the persistent data container of the snapshot
* <p>
* Equivalent to {@code data(Identifier.PERSISTENT_DATA, persistentData)}
* </p>
*
* @param persistentData The persistent data container data
* @return The builder
* @since 3.0
*/
@NotNull
public Builder persistentData(@NotNull Data.PersistentData persistentData) {
return data(Identifier.PERSISTENT_DATA, persistentData);
}
/**
* Build the {@link DataSnapshot}
*
* @return The {@link DataSnapshot.Unpacked snapshot}
* @throws IllegalStateException If no save cause is specified
* @since 3.0
*/
@NotNull
public DataSnapshot.Unpacked build() throws IllegalStateException {
if (saveCause == null) {
throw new IllegalStateException("Cannot build DataSnapshot without a save cause");
}
return new Unpacked(
UUID.randomUUID(),
pinned || plugin.getSettings().doAutoPin(saveCause),
timestamp,
saveCause,
data,
plugin.getMinecraftVersion(),
plugin.getPlatformType(),
DataSnapshot.CURRENT_FORMAT_VERSION
);
}
/**
* Build and pack the {@link DataSnapshot}
*
* @return The {@link DataSnapshot.Packed snapshot}
* @throws IllegalStateException If no save cause is specified
* @since 3.0
*/
@NotNull
public DataSnapshot.Packed buildAndPack() throws IllegalStateException {
return build().pack(plugin);
}
}
/**
* Identifies the cause of a player data save.
*
* @implNote This enum is saved in the database.
* </p>
* Cause names have a max length of 32 characters.
*/
public enum SaveCause {
/**
* Indicates data saved when a player disconnected from the server (either to change servers, or to log off)
*
* @since 2.0
*/
DISCONNECT,
/**
* Indicates data saved when the world saved
*
* @since 2.0
*/
WORLD_SAVE,
/**
* Indicates data saved when the user died
*
* @since 2.1
*/
DEATH,
/**
* Indicates data saved when the server shut down
*
* @since 2.0
*/
SERVER_SHUTDOWN,
/**
* Indicates data was saved by editing inventory contents via the {@code /inventory} command
*
* @since 2.0
*/
INVENTORY_COMMAND,
/**
* Indicates data was saved by editing Ender Chest contents via the {@code /enderchest} command
*
* @since 2.0
*/
ENDERCHEST_COMMAND,
/**
* Indicates data was saved by restoring it from a previous version
*
* @since 2.0
*/
BACKUP_RESTORE,
/**
* Indicates data was saved by an API call
*
* @since 2.0
*/
API,
/**
* Indicates data was saved from being imported from MySQLPlayerDataBridge
*
* @since 2.0
*/
MPDB_MIGRATION,
/**
* Indicates data was saved from being imported from a legacy version (v1.x -> v2.x)
*
* @since 2.0
*/
LEGACY_MIGRATION,
/**
* Indicates data was saved from being imported from a legacy version (v2.x -> v3.x)
*
* @since 3.0
*/
CONVERTED_FROM_V2;
@NotNull
public String getDisplayName() {
return Locales.truncate(name().toLowerCase(Locale.ENGLISH)
.replaceAll("_", " "), 18);
}
}
/**
* Represents the cause of a player having their data updated.
*/
public enum UpdateCause {
/**
* Indicates the data was updated by a synchronization process
*
* @since 3.0
*/
SYNCHRONIZED("synchronization_complete", "synchronization_failed"),
/**
* Indicates the data was updated by a user joining the server
*
* @since 3.0
*/
NEW_USER("user_registration_complete", null),
/**
* Indicates the data was updated by a data update process (management command, API, etc.)
*
* @since 3.0
*/
UPDATED("data_update_complete", "data_update_failed");
private final String completedLocale;
private final String failureLocale;
UpdateCause(@Nullable String completedLocale, @Nullable String failureLocale) {
this.completedLocale = completedLocale;
this.failureLocale = failureLocale;
}
public Optional<MineDown> getCompletedLocale(@NotNull HuskSync plugin) {
if (completedLocale != null) {
return plugin.getLocales().getLocale(completedLocale);
}
return Optional.empty();
}
public Optional<MineDown> getFailedLocale(@NotNull HuskSync plugin) {
if (failureLocale != null) {
return plugin.getLocales().getLocale(failureLocale);
}
return Optional.empty();
}
}
}

View File

@@ -0,0 +1,177 @@
/*
* 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.data;
import net.kyori.adventure.key.InvalidKeyException;
import net.kyori.adventure.key.Key;
import org.intellij.lang.annotations.Subst;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.Map;
import java.util.stream.Stream;
/**
* Identifiers of different types of {@link Data}s
*/
public class Identifier {
public static Identifier INVENTORY = huskSync("inventory", true);
public static Identifier ENDER_CHEST = huskSync("ender_chest", true);
public static Identifier POTION_EFFECTS = huskSync("potion_effects", true);
public static Identifier ADVANCEMENTS = huskSync("advancements", true);
public static Identifier LOCATION = huskSync("location", false);
public static Identifier STATISTICS = huskSync("statistics", true);
public static Identifier HEALTH = huskSync("health", true);
public static Identifier HUNGER = huskSync("hunger", true);
public static Identifier EXPERIENCE = huskSync("experience", true);
public static Identifier GAME_MODE = huskSync("game_mode", true);
public static Identifier PERSISTENT_DATA = huskSync("persistent_data", true);
private final Key key;
private final boolean configDefault;
private Identifier(@NotNull Key key, boolean configDefault) {
this.key = key;
this.configDefault = configDefault;
}
/**
* Create an identifier from a {@link Key}
*
* @param key the key
* @return the identifier
* @since 3.0
*/
@NotNull
public static Identifier from(@NotNull Key key) {
if (key.namespace().equals("husksync")) {
throw new IllegalArgumentException("You cannot register a key with \"husksync\" as the namespace!");
}
return new Identifier(key, true);
}
/**
* Create an identifier from a namespace and value
*
* @param plugin the namespace
* @param name the value
* @return the identifier
* @since 3.0
*/
@NotNull
public static Identifier from(@Subst("plugin") @NotNull String plugin, @Subst("null") @NotNull String name) {
return from(Key.key(plugin, name));
}
@NotNull
private static Identifier huskSync(@Subst("null") @NotNull String name,
boolean configDefault) throws InvalidKeyException {
return new Identifier(Key.key("husksync", name), configDefault);
}
@NotNull
@SuppressWarnings("unused")
private static Identifier parse(@NotNull String key) throws InvalidKeyException {
return huskSync(key, true);
}
public boolean isEnabledByDefault() {
return configDefault;
}
@NotNull
private Map.Entry<String, Boolean> getConfigEntry() {
return Map.entry(getKeyValue(), configDefault);
}
/**
* <b>(Internal use only)</b> - Get a map of the default config entries for all HuskSync identifiers
*
* @return a map of all the config entries
* @since 3.0
*/
@NotNull
@ApiStatus.Internal
@SuppressWarnings("unchecked")
public static Map<String, Boolean> getConfigMap() {
return Map.ofEntries(Stream.of(
INVENTORY, ENDER_CHEST, POTION_EFFECTS, ADVANCEMENTS, LOCATION,
STATISTICS, HEALTH, HUNGER, EXPERIENCE, GAME_MODE, PERSISTENT_DATA
)
.map(Identifier::getConfigEntry)
.toArray(Map.Entry[]::new));
}
/**
* Get the namespace of the identifier
*
* @return the namespace
*/
@NotNull
public String getKeyNamespace() {
return key.namespace();
}
/**
* Get the value of the identifier
*
* @return the value
*/
@NotNull
public String getKeyValue() {
return key.value();
}
/**
* Returns {@code true} if the identifier is a custom (non-HuskSync) identifier
*
* @return {@code false} if {@link #getKeyNamespace()} returns "husksync"; {@code true} otherwise
*/
public boolean isCustom() {
return !getKeyNamespace().equals("husksync");
}
/**
* Returns the identifier as a string (the key)
*
* @return the identifier as a string
*/
@NotNull
@Override
public String toString() {
return key.asString();
}
/**
* Returns {@code true} if the given object is an identifier with the same key as this identifier
*
* @param obj the object to compare
* @return {@code true} if the given object is an identifier with the same key as this identifier
*/
@Override
public boolean equals(Object obj) {
if (obj instanceof Identifier other) {
return key.equals(other.key);
}
return false;
}
}

View File

@@ -1,63 +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.data;
import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.NotNull;
/**
* Stores information about the contents of a player's inventory or Ender Chest.
*/
public class ItemData {
/**
* A Base-64 string of platform-serialized items
*/
@SerializedName("serialized_items")
public String serializedItems;
/**
* Get an empty item data object, representing an empty inventory or Ender Chest
*
* @return an empty item data object
*/
@NotNull
public static ItemData empty() {
return new ItemData("");
}
public ItemData(@NotNull final String serializedItems) {
this.serializedItems = serializedItems;
}
@SuppressWarnings("unused")
protected ItemData() {
}
/**
* Check if the item data is empty
*
* @return {@code true} if the item data is empty; {@code false} otherwise
*/
public boolean isEmpty() {
return serializedItems.isEmpty();
}
}

View File

@@ -1,48 +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.data;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import org.jetbrains.annotations.NotNull;
import java.nio.charset.StandardCharsets;
public class JsonDataAdapter implements DataAdapter {
@Override
public byte[] toBytes(@NotNull UserData data) throws DataAdaptionException {
return toJson(data, false).getBytes(StandardCharsets.UTF_8);
}
@Override
public @NotNull String toJson(@NotNull UserData data, boolean pretty) throws DataAdaptionException {
return (pretty ? new GsonBuilder().setPrettyPrinting() : new GsonBuilder()).create().toJson(data);
}
@Override
public @NotNull UserData fromBytes(byte[] data) throws DataAdaptionException {
try {
return new GsonBuilder().create().fromJson(new String(data, StandardCharsets.UTF_8), UserData.class);
} catch (JsonSyntaxException e) {
throw new DataAdaptionException("Failed to parse JSON data", e);
}
}
}

View File

@@ -1,92 +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.data;
import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
/**
* Stores information about a player's location
*/
public class LocationData {
/**
* Name of the world on the server
*/
@SerializedName("world_name")
public String worldName;
/**
* Unique id of the world
*/
@SerializedName("world_uuid")
public UUID worldUuid;
/**
* The environment type of the world (one of "NORMAL", "NETHER", "THE_END")
*/
@SerializedName("world_environment")
public String worldEnvironment;
/**
* The x coordinate of the location
*/
@SerializedName("x")
public double x;
/**
* The y coordinate of the location
*/
@SerializedName("y")
public double y;
/**
* The z coordinate of the location
*/
@SerializedName("z")
public double z;
/**
* The location's facing yaw angle
*/
@SerializedName("yaw")
public float yaw;
/**
* The location's facing pitch angle
*/
@SerializedName("pitch")
public float pitch;
public LocationData(@NotNull String worldName, @NotNull UUID worldUuid,
@NotNull String worldEnvironment,
double x, double y, double z,
float yaw, float pitch) {
this.worldName = worldName;
this.worldUuid = worldUuid;
this.worldEnvironment = worldEnvironment;
this.x = x;
this.y = y;
this.z = z;
this.yaw = yaw;
this.pitch = pitch;
}
@SuppressWarnings("unused")
protected LocationData() {
}
}

View File

@@ -1,73 +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.data;
import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.NotNull;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* Store's a user's persistent data container, holding a map of plugin-set persistent values
*/
public class PersistentDataContainerData {
/**
* Map of namespaced key strings to a byte array representing the persistent data
*/
@SerializedName("persistent_data_map")
protected Map<String, PersistentDataTag<?>> persistentDataMap;
public PersistentDataContainerData(@NotNull Map<String, PersistentDataTag<?>> persistentDataMap) {
this.persistentDataMap = persistentDataMap;
}
@SuppressWarnings("unused")
protected PersistentDataContainerData() {
}
public <T> Optional<T> getTagValue(@NotNull String tagName, @NotNull Class<T> tagClass) {
if (!persistentDataMap.containsKey(tagName)) {
return Optional.empty();
}
// If the tag cannot be cast to the specified class, return an empty optional
final boolean canCast = tagClass.isAssignableFrom(persistentDataMap.get(tagName).value.getClass());
if (!canCast) {
return Optional.empty();
}
return Optional.of(tagClass.cast(persistentDataMap.get(tagName).value));
}
public Optional<PersistentDataTagType> getTagType(@NotNull String tagType) {
if (persistentDataMap.containsKey(tagType)) {
return PersistentDataTagType.getDataType(persistentDataMap.get(tagType).type);
}
return Optional.empty();
}
public Set<String> getTags() {
return persistentDataMap.keySet();
}
}

View File

@@ -1,54 +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.data;
import org.jetbrains.annotations.NotNull;
import java.util.Optional;
/**
* Represents a persistent data tag set by a plugin.
*/
public class PersistentDataTag<T> {
/**
* The enumerated primitive data type name value of the tag
*/
protected String type;
/**
* The value of the tag
*/
public T value;
public PersistentDataTag(@NotNull PersistentDataTagType type, @NotNull T value) {
this.type = type.name();
this.value = value;
}
@SuppressWarnings("unused")
private PersistentDataTag() {
}
public Optional<PersistentDataTagType> getType() {
return PersistentDataTagType.getDataType(type);
}
}

View File

@@ -21,34 +21,24 @@ package net.william278.husksync.data;
import org.jetbrains.annotations.NotNull;
import java.util.Optional;
public interface Serializer<T extends Data> {
/**
* Represents the type of a {@link PersistentDataTag}
*/
public enum PersistentDataTagType {
T deserialize(@NotNull String serialized) throws DeserializationException;
BYTE,
SHORT,
INTEGER,
LONG,
FLOAT,
DOUBLE,
STRING,
BYTE_ARRAY,
INTEGER_ARRAY,
LONG_ARRAY,
TAG_CONTAINER_ARRAY,
TAG_CONTAINER;
@NotNull
String serialize(@NotNull T element) throws SerializationException;
public static Optional<PersistentDataTagType> getDataType(@NotNull String typeName) {
for (PersistentDataTagType type : values()) {
if (type.name().equalsIgnoreCase(typeName)) {
return Optional.of(type);
}
static final class DeserializationException extends IllegalStateException {
DeserializationException(@NotNull String message, @NotNull Throwable cause) {
super(message, cause);
}
return Optional.empty();
}
static final class SerializationException extends IllegalStateException {
SerializationException(@NotNull String message, @NotNull Throwable cause) {
super(message, cause);
}
}
}

View File

@@ -1,70 +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.data;
import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.NotNull;
import java.util.Map;
/**
* Stores information about a player's statistics
*/
public class StatisticsData {
/**
* Map of generic statistic names to their values
*/
@SerializedName("untyped_statistics")
public Map<String, Integer> untypedStatistics;
/**
* Map of block type statistics to a map of material types to values
*/
@SerializedName("block_statistics")
public Map<String, Map<String, Integer>> blockStatistics;
/**
* Map of item type statistics to a map of material types to values
*/
@SerializedName("item_statistics")
public Map<String, Map<String, Integer>> itemStatistics;
/**
* Map of entity type statistics to a map of entity types to values
*/
@SerializedName("entity_statistics")
public Map<String, Map<String, Integer>> entityStatistics;
public StatisticsData(@NotNull Map<String, Integer> untypedStatistics,
@NotNull Map<String, Map<String, Integer>> blockStatistics,
@NotNull Map<String, Map<String, Integer>> itemStatistics,
@NotNull Map<String, Map<String, Integer>> entityStatistics) {
this.untypedStatistics = untypedStatistics;
this.blockStatistics = blockStatistics;
this.itemStatistics = itemStatistics;
this.entityStatistics = entityStatistics;
}
@SuppressWarnings("unused")
protected StatisticsData() {
}
}

View File

@@ -1,123 +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.data;
import com.google.gson.annotations.SerializedName;
/**
* Stores status information about a player
*/
public class StatusData {
/**
* The player's health points
*/
@SerializedName("health")
public double health;
/**
* The player's maximum health points
*/
@SerializedName("max_health")
public double maxHealth;
/**
* The player's health scaling factor
*/
@SerializedName("health_scale")
public double healthScale;
/**
* The player's hunger points
*/
@SerializedName("hunger")
public int hunger;
/**
* The player's saturation points
*/
@SerializedName("saturation")
public float saturation;
/**
* The player's saturation exhaustion points
*/
@SerializedName("saturation_exhaustion")
public float saturationExhaustion;
/**
* The player's currently selected item slot
*/
@SerializedName("selected_item_slot")
public int selectedItemSlot;
/**
* The player's total experience points<p>
* (not to be confused with <i>experience level</i> - this is the "points" value shown on the death screen)
*/
@SerializedName("total_experience")
public int totalExperience;
/**
* The player's experience level (shown on the exp bar)
*/
@SerializedName("experience_level")
public int expLevel;
/**
* The player's progress to their next experience level
*/
@SerializedName("experience_progress")
public float expProgress;
/**
* The player's game mode string (one of "SURVIVAL", "CREATIVE", "ADVENTURE", "SPECTATOR")
*/
@SerializedName("game_mode")
public String gameMode;
/**
* If the player is currently flying
*/
@SerializedName("is_flying")
public boolean isFlying;
public StatusData(final double health, final double maxHealth, final double healthScale,
final int hunger, final float saturation, final float saturationExhaustion,
final int selectedItemSlot, final int totalExperience, final int expLevel,
final float expProgress, final String gameMode, final boolean isFlying) {
this.health = health;
this.maxHealth = maxHealth;
this.healthScale = healthScale;
this.hunger = hunger;
this.saturation = saturation;
this.saturationExhaustion = saturationExhaustion;
this.selectedItemSlot = selectedItemSlot;
this.totalExperience = totalExperience;
this.expLevel = expLevel;
this.expProgress = expProgress;
this.gameMode = gameMode;
this.isFlying = isFlying;
}
@SuppressWarnings("unused")
protected StatusData() {
}
}

View File

@@ -1,77 +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.data;
import net.william278.husksync.config.Settings;
import org.jetbrains.annotations.NotNull;
import java.util.Arrays;
import java.util.List;
/**
* Flags for setting {@link StatusData}, indicating which elements should be synced
*
* @deprecated Use the more direct {@link Settings#getSynchronizationFeature(Settings.SynchronizationFeature)} instead
*/
@Deprecated(since = "2.1")
public enum StatusDataFlag {
SET_HEALTH(Settings.SynchronizationFeature.HEALTH),
SET_MAX_HEALTH(Settings.SynchronizationFeature.MAX_HEALTH),
SET_HUNGER(Settings.SynchronizationFeature.HUNGER),
SET_EXPERIENCE(Settings.SynchronizationFeature.EXPERIENCE),
SET_GAME_MODE(Settings.SynchronizationFeature.GAME_MODE),
SET_FLYING(Settings.SynchronizationFeature.LOCATION),
SET_SELECTED_ITEM_SLOT(Settings.SynchronizationFeature.INVENTORIES);
private final Settings.SynchronizationFeature feature;
StatusDataFlag(@NotNull Settings.SynchronizationFeature feature) {
this.feature = feature;
}
/**
* Returns all status data flags
*
* @return all status data flags as a list
* @deprecated Use {@link Settings#getSynchronizationFeature(Settings.SynchronizationFeature)} instead
*/
@NotNull
@Deprecated(since = "2.1")
@SuppressWarnings("unused")
public static List<StatusDataFlag> getAll() {
return Arrays.stream(StatusDataFlag.values()).toList();
}
/**
* Returns all status data flags that are enabled for setting as per the {@link Settings}
*
* @param settings the settings to use for determining which flags are enabled
* @return all status data flags that are enabled for setting
* @deprecated Use {@link Settings#getSynchronizationFeature(Settings.SynchronizationFeature)} instead
*/
@NotNull
@Deprecated(since = "2.1")
public static List<StatusDataFlag> getFromSettings(@NotNull Settings settings) {
return Arrays.stream(StatusDataFlag.values()).filter(
flag -> settings.getSynchronizationFeature(flag.feature)).toList();
}
}

View File

@@ -1,376 +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.data;
import com.google.gson.annotations.SerializedName;
import net.william278.desertwell.util.Version;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Optional;
/**
* Stores data about a user
*/
public class UserData {
/**
* Indicates the version of the {@link UserData} format being used.
* </p>
* This value is to be incremented whenever the format changes.
*/
public static final int CURRENT_FORMAT_VERSION = 3;
/**
* Stores the user's status data, including health, food, etc.
*/
@SerializedName("status")
@Nullable
protected StatusData statusData;
/**
* Stores the user's inventory contents
*/
@SerializedName("inventory")
@Nullable
protected ItemData inventoryData;
/**
* Stores the user's ender chest contents
*/
@SerializedName("ender_chest")
@Nullable
protected ItemData enderChestData;
/**
* Store's the user's potion effects
*/
@SerializedName("potion_effects")
@Nullable
protected PotionEffectData potionEffectData;
/**
* Stores the set of this user's advancements
*/
@SerializedName("advancements")
@Nullable
protected List<AdvancementData> advancementData;
/**
* Stores the user's set of statistics
*/
@SerializedName("statistics")
@Nullable
protected StatisticsData statisticData;
/**
* Store's the user's world location and coordinates
*/
@SerializedName("location")
@Nullable
protected LocationData locationData;
/**
* Stores the user's serialized persistent data container, which contains metadata keys applied by other plugins
*/
@SerializedName("persistent_data_container")
@Nullable
protected PersistentDataContainerData persistentDataContainerData;
/**
* Stores the version of Minecraft this data was generated in
*/
@SerializedName("minecraft_version")
@NotNull
protected String minecraftVersion;
/**
* Stores the version of the data format being used
*/
@SerializedName("format_version")
protected int formatVersion = CURRENT_FORMAT_VERSION;
/**
* Create a new {@link UserData} object with the provided data
*
* @param statusData the user's status data ({@link StatusData})
* @param inventoryData the user's inventory data ({@link ItemData})
* @param enderChestData the user's ender chest data ({@link ItemData})
* @param potionEffectData the user's potion effect data ({@link PotionEffectData})
* @param advancementData the user's advancement data ({@link AdvancementData})
* @param statisticData the user's statistic data ({@link StatisticsData})
* @param locationData the user's location data ({@link LocationData})
* @param persistentDataContainerData the user's persistent data container data ({@link PersistentDataContainerData})
* @param minecraftVersion the version of Minecraft this data was generated in (e.g. {@code "1.19.2"})
* @deprecated see {@link #builder(String)} or {@link #builder(Version)} to create a {@link UserDataBuilder}, which
* you can use to {@link UserDataBuilder#build()} a {@link UserData} instance with
*/
@Deprecated(since = "2.1")
public UserData(@Nullable StatusData statusData, @Nullable ItemData inventoryData,
@Nullable ItemData enderChestData, @Nullable PotionEffectData potionEffectData,
@Nullable List<AdvancementData> advancementData, @Nullable StatisticsData statisticData,
@Nullable LocationData locationData, @Nullable PersistentDataContainerData persistentDataContainerData,
@NotNull String minecraftVersion) {
this.statusData = statusData;
this.inventoryData = inventoryData;
this.enderChestData = enderChestData;
this.potionEffectData = potionEffectData;
this.advancementData = advancementData;
this.statisticData = statisticData;
this.locationData = locationData;
this.persistentDataContainerData = persistentDataContainerData;
this.minecraftVersion = minecraftVersion;
}
// Empty constructor to facilitate json serialization
@SuppressWarnings("unused")
protected UserData() {
}
/**
* Gets the {@link StatusData} from this user data
*
* @return the {@link StatusData} of this user data
* @since 2.0
* @deprecated Use {@link #getStatus()}, which returns an optional instead
*/
@Nullable
@Deprecated(since = "2.1")
public StatusData getStatusData() {
return statusData;
}
/**
* Gets the {@link StatusData} from this user data
*
* @return an optional containing the {@link StatusData} if it is present in this user data
* @since 2.1
*/
public Optional<StatusData> getStatus() {
return Optional.ofNullable(statusData);
}
/**
* Gets the {@link ItemData} representing the player's inventory from this user data
*
* @return the inventory {@link ItemData} of this user data
* @since 2.0
* @deprecated Use {@link #getInventory()}, which returns an optional instead
*/
@Nullable
@Deprecated(since = "2.1")
public ItemData getInventoryData() {
return inventoryData;
}
/**
* Gets the {@link ItemData} representing the player's inventory from this user data
*
* @return an optional containing the inventory {@link ItemData} if it is present in this user data
* @since 2.1
*/
public Optional<ItemData> getInventory() {
return Optional.ofNullable(inventoryData);
}
/**
* Gets the {@link ItemData} representing the player's ender chest from this user data
*
* @return the ender chest {@link ItemData} of this user data
* @since 2.0
* @deprecated Use {@link #getEnderChest()}, which returns an optional instead
*/
@Nullable
@Deprecated(since = "2.1")
public ItemData getEnderChestData() {
return enderChestData;
}
/**
* Gets the {@link ItemData} representing the player's ender chest from this user data
*
* @return an optional containing the ender chest {@link ItemData} if it is present in this user data
* @since 2.1
*/
public Optional<ItemData> getEnderChest() {
return Optional.ofNullable(enderChestData);
}
/**
* Gets the {@link PotionEffectData} representing player status effects from this user data
*
* @return the {@link PotionEffectData} of this user data
* @since 2.0
* @deprecated Use {@link #getPotionEffects()}, which returns an optional instead
*/
@Nullable
@Deprecated(since = "2.1")
public PotionEffectData getPotionEffectsData() {
return potionEffectData;
}
/**
* Gets the {@link PotionEffectData} representing the player's potion effects from this user data
*
* @return an optional containing {@link PotionEffectData} if it is present in this user data
* @since 2.1
*/
public Optional<PotionEffectData> getPotionEffects() {
return Optional.ofNullable(potionEffectData);
}
/**
* Gets the list of {@link AdvancementData} from this user data
*
* @return the {@link AdvancementData} of this user data
* @since 2.0
* @deprecated Use {@link #getAdvancements()}, which returns an optional instead
*/
@Nullable
@Deprecated(since = "2.1")
public List<AdvancementData> getAdvancementData() {
return advancementData;
}
/**
* Gets a list of {@link AdvancementData} representing the player's advancements from this user data
*
* @return an optional containing a {@link List} of {@link AdvancementData} if it is present in this user data
* @since 2.1
*/
public Optional<List<AdvancementData>> getAdvancements() {
return Optional.ofNullable(advancementData);
}
/**
* Gets the {@link StatisticsData} representing player statistics from this user data
*
* @return the {@link StatisticsData} of this user data
* @since 2.0
* @deprecated Use {@link #getStatistics()}, which returns an optional instead
*/
@Nullable
@Deprecated(since = "2.1")
public StatisticsData getStatisticsData() {
return statisticData;
}
/**
* Gets {@link StatisticsData} representing player statistics from this user data
*
* @return an optional containing player {@link StatisticsData} if it is present in this user data
* @since 2.1
*/
public Optional<StatisticsData> getStatistics() {
return Optional.ofNullable(statisticData);
}
/**
* Gets the {@link LocationData} representing the player location from this user data
*
* @return the inventory {@link LocationData} of this user data
* @since 2.0
* @deprecated Use {@link #getLocation()}, which returns an optional instead
*/
@Nullable
@Deprecated(since = "2.1")
public LocationData getLocationData() {
return locationData;
}
/**
* Gets {@link LocationData} representing the player location from this user data
*
* @return an optional containing player {@link LocationData} if it is present in this user data
* @since 2.1
*/
public Optional<LocationData> getLocation() {
return Optional.ofNullable(locationData);
}
/**
* Gets the {@link PersistentDataContainerData} from this user data
*
* @return the {@link PersistentDataContainerData} of this user data
* @since 2.0
* @deprecated Use {@link #getPersistentDataContainer()}, which returns an optional instead
*/
@Nullable
@Deprecated(since = "2.1")
public PersistentDataContainerData getPersistentDataContainerData() {
return persistentDataContainerData;
}
/**
* Gets {@link PersistentDataContainerData} from this user data
*
* @return an optional containing the player's {@link PersistentDataContainerData} if it is present in this user data
* @since 2.1
*/
public Optional<PersistentDataContainerData> getPersistentDataContainer() {
return Optional.ofNullable(persistentDataContainerData);
}
/**
* Get the version of Minecraft this data was generated in
*
* @return the version of Minecraft this data was generated in
*/
@NotNull
public String getMinecraftVersion() {
return minecraftVersion;
}
/**
* Gets the version of the data format being used
*
* @return the version of the data format being used
*/
public int getFormatVersion() {
return formatVersion;
}
/**
* Get a new {@link UserDataBuilder} for creating {@link UserData}
*
* @param minecraftVersion the version of Minecraft this data was generated in (e.g. {@code "1.19.2"})
* @return a UserData {@link UserDataBuilder} instance
* @since 2.1
*/
@NotNull
public static UserDataBuilder builder(@NotNull String minecraftVersion) {
return new UserDataBuilder(minecraftVersion);
}
/**
* Get a new {@link UserDataBuilder} for creating {@link UserData}
*
* @param minecraftVersion a {@link Version} object, representing the Minecraft version this data was generated in
* @return a UserData {@link UserDataBuilder} instance
* @since 2.1
*/
@NotNull
public static UserDataBuilder builder(@NotNull Version minecraftVersion) {
return builder(minecraftVersion.toStringWithoutMetadata());
}
}

View File

@@ -1,159 +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.data;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
/**
* A builder utility for creating {@link UserData} instances
*
* @since 2.1
*/
@SuppressWarnings("UnusedReturnValue")
public class UserDataBuilder {
@NotNull
private final UserData userData;
protected UserDataBuilder(@NotNull String minecraftVersion) {
this.userData = new UserData();
this.userData.minecraftVersion = minecraftVersion;
}
/**
* Set the {@link StatusData} to this {@link UserData}
*
* @param status the {@link StatusData} to set
* @return this {@link UserDataBuilder}
* @since 2.1
*/
@NotNull
public UserDataBuilder setStatus(@NotNull StatusData status) {
this.userData.statusData = status;
return this;
}
/**
* Set the inventory {@link ItemData} to this {@link UserData}
*
* @param inventoryData the inventory {@link ItemData} to set
* @return this {@link UserDataBuilder}
* @since 2.1
*/
@NotNull
public UserDataBuilder setInventory(@Nullable ItemData inventoryData) {
this.userData.inventoryData = inventoryData;
return this;
}
/**
* Set the ender chest {@link ItemData} to this {@link UserData}
*
* @param enderChestData the ender chest {@link ItemData} to set
* @return this {@link UserDataBuilder}
* @since 2.1
*/
@NotNull
public UserDataBuilder setEnderChest(@Nullable ItemData enderChestData) {
this.userData.enderChestData = enderChestData;
return this;
}
/**
* Set the {@link List} of {@link ItemData} to this {@link UserData}
*
* @param potionEffectData the {@link List} of {@link ItemData} to set
* @return this {@link UserDataBuilder}
* @since 2.1
*/
@NotNull
public UserDataBuilder setPotionEffects(@Nullable PotionEffectData potionEffectData) {
this.userData.potionEffectData = potionEffectData;
return this;
}
/**
* Set the {@link List} of {@link ItemData} to this {@link UserData}
*
* @param advancementData the {@link List} of {@link ItemData} to set
* @return this {@link UserDataBuilder}
* @since 2.1
*/
@NotNull
public UserDataBuilder setAdvancements(@Nullable List<AdvancementData> advancementData) {
this.userData.advancementData = advancementData;
return this;
}
/**
* Set the {@link StatisticsData} to this {@link UserData}
*
* @param statisticData the {@link StatisticsData} to set
* @return this {@link UserDataBuilder}
* @since 2.1
*/
@NotNull
public UserDataBuilder setStatistics(@Nullable StatisticsData statisticData) {
this.userData.statisticData = statisticData;
return this;
}
/**
* Set the {@link LocationData} to this {@link UserData}
*
* @param locationData the {@link LocationData} to set
* @return this {@link UserDataBuilder}
* @since 2.1
*/
@NotNull
public UserDataBuilder setLocation(@Nullable LocationData locationData) {
this.userData.locationData = locationData;
return this;
}
/**
* Set the {@link PersistentDataContainerData} to this {@link UserData}
*
* @param persistentDataContainerData the {@link PersistentDataContainerData} to set
* @return this {@link UserDataBuilder}
* @since 2.1
*/
@NotNull
public UserDataBuilder setPersistentDataContainer(@Nullable PersistentDataContainerData persistentDataContainerData) {
this.userData.persistentDataContainerData = persistentDataContainerData;
return this;
}
/**
* Build and get the {@link UserData} instance
*
* @return the {@link UserData} instance
* @since 2.1
*/
@NotNull
public UserData build() {
return this.userData;
}
}

View File

@@ -0,0 +1,167 @@
/*
* 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.data;
import net.william278.desertwell.util.ThrowingConsumer;
import net.william278.husksync.HuskSync;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.Map;
/**
* A holder of data in the form of {@link Data}s, which can be synced
*/
public interface UserDataHolder extends DataHolder {
/**
* Get the data that is enabled for syncing in the config
*
* @return the data that is enabled for syncing
* @since 3.0
*/
@Override
@NotNull
default Map<Identifier, Data> getData() {
return getPlugin().getRegisteredDataTypes().stream()
.filter(type -> type.isCustom() || getPlugin().getSettings().isSyncFeatureEnabled(type))
.map(id -> Map.entry(id, getData(id)))
.filter(data -> data.getValue().isPresent())
.collect(HashMap::new, (map, data) -> map.put(data.getKey(), data.getValue().get()), HashMap::putAll);
}
/**
* Apply the data for the given {@link Identifier} to the holder.
* <p>
* This will be performed synchronously on the main server thread; it will not happen instantly.
*
* @param identifier the {@link Identifier} to set the data for
* @param data the {@link Data} to set
* @since 3.0
*/
@Override
default void setData(@NotNull Identifier identifier, @NotNull Data data) {
getPlugin().runSync(() -> data.apply(this, getPlugin()));
}
/**
* Create a serialized data snapshot of this data owner
*
* @param saveCause the cause of the snapshot
* @return the snapshot
* @since 3.0
*/
@NotNull
default DataSnapshot.Packed createSnapshot(@NotNull DataSnapshot.SaveCause saveCause) {
return DataSnapshot.builder(getPlugin()).data(this.getData()).saveCause(saveCause).buildAndPack();
}
/**
* Deserialize and apply a data snapshot to this data owner
* <p>
* This method will deserialize the data on the current thread, then synchronously apply it on
* the main server thread.
* </p>
* The {@code runAfter} callback function will be run after the snapshot has been applied.
*
* @param snapshot the snapshot to apply
* @param runAfter the function to run asynchronously after the snapshot has been applied
* @since 3.0
*/
default void applySnapshot(@NotNull DataSnapshot.Packed snapshot, @NotNull ThrowingConsumer<UserDataHolder> runAfter) {
final HuskSync plugin = getPlugin();
final DataSnapshot.Unpacked unpacked = snapshot.unpack(plugin);
plugin.runSync(() -> {
unpacked.getData().forEach((type, data) -> {
if (plugin.getSettings().isSyncFeatureEnabled(type)) {
if (type.isCustom()) {
getCustomDataStore().put(type, data);
}
data.apply(this, plugin);
}
});
plugin.runAsync(() -> runAfter.accept(this));
});
}
@Override
default void setInventory(@NotNull Data.Items.Inventory inventory) {
this.setData(Identifier.INVENTORY, inventory);
}
@Override
default void setEnderChest(@NotNull Data.Items.EnderChest enderChest) {
this.setData(Identifier.ENDER_CHEST, enderChest);
}
@Override
default void setPotionEffects(@NotNull Data.PotionEffects potionEffects) {
this.setData(Identifier.POTION_EFFECTS, potionEffects);
}
@Override
default void setAdvancements(@NotNull Data.Advancements advancements) {
this.setData(Identifier.ADVANCEMENTS, advancements);
}
@Override
default void setLocation(@NotNull Data.Location location) {
this.setData(Identifier.LOCATION, location);
}
@Override
default void setStatistics(@NotNull Data.Statistics statistics) {
this.setData(Identifier.STATISTICS, statistics);
}
@Override
default void setHealth(@NotNull Data.Health health) {
this.setData(Identifier.HEALTH, health);
}
@Override
default void setHunger(@NotNull Data.Hunger hunger) {
this.setData(Identifier.HUNGER, hunger);
}
@Override
default void setExperience(@NotNull Data.Experience experience) {
this.setData(Identifier.EXPERIENCE, experience);
}
@Override
default void setGameMode(@NotNull Data.GameMode gameMode) {
this.setData(Identifier.GAME_MODE, gameMode);
}
@Override
default void setPersistentData(@NotNull Data.PersistentData persistentData) {
this.setData(Identifier.PERSISTENT_DATA, persistentData);
}
@NotNull
Map<Identifier, Data> getCustomDataStore();
@NotNull
@ApiStatus.Internal
HuskSync getPlugin();
}

View File

@@ -1,145 +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.data;
import net.william278.husksync.command.Permission;
import net.william278.husksync.config.Locales;
import net.william278.husksync.player.OnlineUser;
import net.william278.husksync.player.User;
import org.jetbrains.annotations.NotNull;
import java.text.SimpleDateFormat;
import java.util.*;
/**
* Represents a uniquely versioned and timestamped snapshot of a user's data, including why it was saved.
*
* @param versionUUID The unique identifier for this user data version
* @param versionTimestamp An epoch milliseconds timestamp of when this data was created
* @param userData The {@link UserData} that has been versioned
* @param cause The {@link DataSaveCause} that caused this data to be saved
*/
public record UserDataSnapshot(@NotNull UUID versionUUID, @NotNull Date versionTimestamp,
@NotNull DataSaveCause cause, boolean pinned,
@NotNull UserData userData) implements Comparable<UserDataSnapshot> {
/**
* Version {@link UserData} into a {@link UserDataSnapshot}, assigning it a random {@link UUID} and the current timestamp {@link Date}
* </p>
* Note that this method will set {@code cause} to {@link DataSaveCause#API}
*
* @param userData The {@link UserData} to version
* @return A new {@link UserDataSnapshot}
* @implNote This isn't used to version data that is going to be set to a database to prevent UUID collisions.<p>
* Database implementations should instead use their own UUID generation functions.
*/
public static UserDataSnapshot create(@NotNull UserData userData) {
return new UserDataSnapshot(UUID.randomUUID(), new Date(),
DataSaveCause.API, false, userData);
}
/**
* Display a menu in chat to an {@link OnlineUser} about this {@link UserDataSnapshot} for a {@link User dataOwner}
*
* @param user The {@link OnlineUser} to display the menu to
* @param dataOwner The {@link User} whose data this snapshot captures a state of
* @param locales The {@link Locales} to use for displaying the menu
*/
public void displayDataOverview(@NotNull OnlineUser user, @NotNull User dataOwner, @NotNull Locales locales) {
// Title message, timestamp, owner and cause.
locales.getLocale("data_manager_title", versionUUID().toString().split("-")[0],
versionUUID().toString(), dataOwner.username, dataOwner.uuid.toString())
.ifPresent(user::sendMessage);
locales.getLocale("data_manager_timestamp",
new SimpleDateFormat("MMM dd yyyy, HH:mm:ss.sss").format(versionTimestamp()))
.ifPresent(user::sendMessage);
if (pinned()) {
locales.getLocale("data_manager_pinned").ifPresent(user::sendMessage);
}
locales.getLocale("data_manager_cause", cause().name().toLowerCase(Locale.ENGLISH).replaceAll("_", " "))
.ifPresent(user::sendMessage);
// User status data, if present in the snapshot
userData().getStatus()
.flatMap(statusData -> locales.getLocale("data_manager_status",
Integer.toString((int) statusData.health),
Integer.toString((int) statusData.maxHealth),
Integer.toString(statusData.hunger),
Integer.toString(statusData.expLevel),
statusData.gameMode.toLowerCase(Locale.ENGLISH)))
.ifPresent(user::sendMessage);
// Advancement and statistic data, if both are present in the snapshot
userData().getAdvancements()
.flatMap(advancementData -> userData().getStatistics()
.flatMap(statisticsData -> locales.getLocale("data_manager_advancements_statistics",
Integer.toString(advancementData.size()),
generateAdvancementPreview(advancementData, locales),
String.format("%.2f", (((statisticsData.untypedStatistics.getOrDefault(
"PLAY_ONE_MINUTE", 0)) / 20d) / 60d) / 60d))))
.ifPresent(user::sendMessage);
if (user.hasPermission(Permission.COMMAND_INVENTORY.node)
&& user.hasPermission(Permission.COMMAND_ENDER_CHEST.node)) {
locales.getLocale("data_manager_item_buttons", dataOwner.username, versionUUID().toString())
.ifPresent(user::sendMessage);
}
if (user.hasPermission(Permission.COMMAND_USER_DATA_MANAGE.node)) {
locales.getLocale("data_manager_management_buttons", dataOwner.username, versionUUID().toString())
.ifPresent(user::sendMessage);
}
if (user.hasPermission(Permission.COMMAND_USER_DATA_DUMP.node)) {
locales.getLocale("data_manager_system_buttons", dataOwner.username, versionUUID().toString())
.ifPresent(user::sendMessage);
}
}
@NotNull
private String generateAdvancementPreview(@NotNull List<AdvancementData> advancementData, @NotNull Locales locales) {
final StringJoiner joiner = new StringJoiner("\n");
final List<AdvancementData> advancementsToPreview = advancementData.stream().filter(dataItem ->
!dataItem.key.startsWith("minecraft:recipes/")).toList();
final int PREVIEW_SIZE = 8;
for (int i = 0; i < advancementsToPreview.size(); i++) {
joiner.add(advancementsToPreview.get(i).key);
if (i >= PREVIEW_SIZE) {
break;
}
}
final int remainingAdvancements = advancementsToPreview.size() - PREVIEW_SIZE;
if (remainingAdvancements > 0) {
joiner.add(locales.getRawLocale("data_manager_advancements_preview_remaining",
Integer.toString(remainingAdvancements)).orElse("+" + remainingAdvancements + ""));
}
return joiner.toString();
}
/**
* Compare UserData by creation timestamp
*
* @param other the other UserData to be compared
* @return the comparison result; the more recent UserData is greater than the less recent UserData
*/
@Override
public int compareTo(@NotNull UserDataSnapshot other) {
return Long.compare(this.versionTimestamp.getTime(), other.versionTimestamp.getTime());
}
}

View File

@@ -21,20 +21,20 @@ package net.william278.husksync.database;
import net.william278.husksync.HuskSync;
import net.william278.husksync.config.Settings;
import net.william278.husksync.data.DataSaveCause;
import net.william278.husksync.data.UserData;
import net.william278.husksync.data.UserDataSnapshot;
import net.william278.husksync.migrator.Migrator;
import net.william278.husksync.player.User;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.data.DataSnapshot.SaveCause;
import net.william278.husksync.data.UserDataHolder;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* An abstract representation of the plugin database, storing player data.
@@ -57,17 +57,19 @@ public abstract class Database {
* @throws IOException if the resource could not be read
*/
@SuppressWarnings("SameParameterValue")
@NotNull
protected final String[] getSchemaStatements(@NotNull String schemaFileName) throws IOException {
return formatStatementTables(new String(Objects.requireNonNull(plugin.getResource(schemaFileName))
.readAllBytes(), StandardCharsets.UTF_8)).split(";");
}
/**
* Format all table name placeholder strings in a SQL statement
* Format all table name placeholder strings in an SQL statement
*
* @param sql the SQL statement with un-formatted table name placeholders
* @param sql the SQL statement with unformatted table name placeholders
* @return the formatted statement, with table placeholders replaced with the correct names
*/
@NotNull
protected final String formatStatementTables(@NotNull String sql) {
return sql.replaceAll("%users_table%", plugin.getSettings().getTableName(Settings.TableName.USERS))
.replaceAll("%user_data_table%", plugin.getSettings().getTableName(Settings.TableName.USER_DATA));
@@ -75,57 +77,67 @@ public abstract class Database {
/**
* Initialize the database and ensure tables are present; create tables if they do not exist.
*
* @throws IllegalStateException if the database could not be initialized
*/
public abstract void initialize();
@Blocking
public abstract void initialize() throws IllegalStateException;
/**
* Ensure a {@link User} has an entry in the database and that their username is up-to-date
*
* @param user The {@link User} to ensure
* @return A future returning void when complete
*/
public abstract CompletableFuture<Void> ensureUser(@NotNull User user);
@Blocking
public abstract void ensureUser(@NotNull User user);
/**
* Get a player by their Minecraft account {@link UUID}
*
* @param uuid Minecraft account {@link UUID} of the {@link User} to get
* @return A future returning an optional with the {@link User} present if they exist
* @return An optional with the {@link User} present if they exist
*/
public abstract CompletableFuture<Optional<User>> getUser(@NotNull UUID uuid);
@Blocking
public abstract Optional<User> getUser(@NotNull UUID uuid);
/**
* Get a user by their username (<i>case-insensitive</i>)
*
* @param username Username of the {@link User} to get (<i>case-insensitive</i>)
* @return A future returning an optional with the {@link User} present if they exist
* @return An optional with the {@link User} present if they exist
*/
public abstract CompletableFuture<Optional<User>> getUserByName(@NotNull String username);
@Blocking
public abstract Optional<User> getUserByName(@NotNull String username);
/**
* Get the current uniquely versioned user data for a given user, if it exists.
*
* @param user the user to get data for
* @return an optional containing the {@link UserDataSnapshot}, if it exists, or an empty optional if it does not
*/
public abstract CompletableFuture<Optional<UserDataSnapshot>> getCurrentUserData(@NotNull User user);
/**
* Get all {@link UserDataSnapshot} entries for a user from the database.
* Get the latest data snapshot for a user.
*
* @param user The user to get data for
* @return A future returning a list of a user's {@link UserDataSnapshot} entries
* @return an optional containing the {@link DataSnapshot}, if it exists, or an empty optional if it does not
*/
public abstract CompletableFuture<List<UserDataSnapshot>> getUserData(@NotNull User user);
@Blocking
public abstract Optional<DataSnapshot.Packed> getLatestSnapshot(@NotNull User user);
/**
* Gets a specific {@link UserDataSnapshot} entry for a user from the database, by its UUID.
* Get all {@link DataSnapshot} entries for a user from the database.
*
* @param user The user to get data for
* @return The list of a user's {@link DataSnapshot} entries
*/
@Blocking
@NotNull
public abstract List<DataSnapshot.Packed> getAllSnapshots(@NotNull User user);
/**
* Gets a specific {@link DataSnapshot} entry for a user from the database, by its UUID.
*
* @param user The user to get data for
* @param versionUuid The UUID of the {@link UserDataSnapshot} entry to get
* @return A future returning an optional containing the {@link UserDataSnapshot}, if it exists, or an empty optional if it does not
* @param versionUuid The UUID of the {@link DataSnapshot} entry to get
* @return An optional containing the {@link DataSnapshot}, if it exists
*/
public abstract CompletableFuture<Optional<UserDataSnapshot>> getUserData(@NotNull User user, @NotNull UUID versionUuid);
@Blocking
public abstract Optional<DataSnapshot.Packed> getSnapshot(@NotNull User user, @NotNull UUID versionUuid);
/**
* <b>(Internal)</b> Prune user data for a given user to the maximum value as configured.
@@ -133,61 +145,131 @@ public abstract class Database {
* @param user The user to prune data for
* @implNote Data snapshots marked as {@code pinned} are exempt from rotation
*/
protected abstract void rotateUserData(@NotNull User user);
@Blocking
protected abstract void rotateSnapshots(@NotNull User user);
/**
* Deletes a specific {@link UserDataSnapshot} entry for a user from the database, by its UUID.
* Deletes a specific {@link DataSnapshot} entry for a user from the database, by its UUID.
*
* @param user The user to get data for
* @param versionUuid The UUID of the {@link UserDataSnapshot} entry to delete
* @return A future returning void when complete
* @param versionUuid The UUID of the {@link DataSnapshot} entry to delete
*/
public abstract CompletableFuture<Boolean> deleteUserData(@NotNull User user, @NotNull UUID versionUuid);
@Blocking
public abstract boolean deleteSnapshot(@NotNull User user, @NotNull UUID versionUuid);
/**
* Save user data to the database<p>
* Save user data to the database
* </p>
* This will remove the oldest data for the user if the amount of data exceeds the limit as configured
*
* @param user The user to add data for
* @param userData The {@link UserData} to set. The implementation should version it with a random UUID and the current timestamp during insertion.
* @return A future returning void when complete
* @see UserDataSnapshot#create(UserData)
* @param snapshot The {@link DataSnapshot} to set.
* The implementation should version it with a random UUID and the current timestamp during insertion.
* @see UserDataHolder#createSnapshot(SaveCause)
*/
public abstract CompletableFuture<Void> setUserData(@NotNull User user, @NotNull UserData userData, @NotNull DataSaveCause dataSaveCause);
@Blocking
public void addSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed snapshot) {
if (snapshot.getSaveCause() != SaveCause.SERVER_SHUTDOWN) {
plugin.fireEvent(
plugin.getDataSaveEvent(user, snapshot),
(event) -> this.addAndRotateSnapshot(user, snapshot)
);
return;
}
this.addAndRotateSnapshot(user, snapshot);
}
/**
* Pin a saved {@link UserDataSnapshot} by given version UUID, setting it's {@code pinned} state to {@code true}.
* <b>Internal</b> - Save user data to the database. This will:
* <ol>
* <li>Delete their most recent snapshot, if it was created before the backup frequency time</li>
* <li>Create the snapshot</li>
* <li>Rotate snapshot backups</li>
* </ol>
*
* @param user The user to pin the data for
* @param versionUuid The UUID of the user's {@link UserDataSnapshot} entry to pin
* @return A future returning a boolean; {@code true} if the operation completed successfully, {@code false} if it failed
* @see UserDataSnapshot#pinned()
* @param user The user to add data for
* @param snapshot The {@link DataSnapshot} to set.
*/
public abstract CompletableFuture<Void> pinUserData(@NotNull User user, @NotNull UUID versionUuid);
@Blocking
private void addAndRotateSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed snapshot) {
final int backupFrequency = plugin.getSettings().getBackupFrequency();
if (!snapshot.isPinned() && backupFrequency > 0) {
this.rotateLatestSnapshot(user, snapshot.getTimestamp().minusHours(backupFrequency));
}
this.createSnapshot(user, snapshot);
this.rotateSnapshots(user);
}
/**
* Unpin a saved {@link UserDataSnapshot} by given version UUID, setting it's {@code pinned} state to {@code false}.
* Deletes the most recent data snapshot by the given {@link User user}
* The snapshot must have been created after {@link OffsetDateTime time} and NOT be pinned
* Facilities the backup frequency feature, reducing redundant snapshots from being saved longer than needed
*
* @param user The user to delete a snapshot for
* @param within The time to delete a snapshot after
*/
@Blocking
protected abstract void rotateLatestSnapshot(@NotNull User user, @NotNull OffsetDateTime within);
/**
* <b>Internal</b> - Create user data in the database
*
* @param user The user to add data for
* @param data The {@link DataSnapshot} to set.
*/
@Blocking
protected abstract void createSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed data);
/**
* Update a saved {@link DataSnapshot} by given version UUID
*
* @param user The user whose data snapshot
* @param snapshot The {@link DataSnapshot} to update
*/
@Blocking
public abstract void updateSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed snapshot);
/**
* Unpin a saved {@link DataSnapshot} by given version UUID, setting it's {@code pinned} state to {@code false}.
*
* @param user The user to unpin the data for
* @param versionUuid The UUID of the user's {@link UserDataSnapshot} entry to unpin
* @return A future returning a boolean; {@code true} if the operation completed successfully, {@code false} if it failed
* @see UserDataSnapshot#pinned()
* @param versionUuid The UUID of the user's {@link DataSnapshot} entry to unpin
* @see DataSnapshot#isPinned()
*/
public abstract CompletableFuture<Void> unpinUserData(@NotNull User user, @NotNull UUID versionUuid);
@Blocking
public final void unpinSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
this.getSnapshot(user, versionUuid).ifPresent(data -> {
data.edit(plugin, (snapshot) -> snapshot.setPinned(false));
this.updateSnapshot(user, data);
});
}
/**
* Wipes <b>all</b> {@link UserData} entries from the database.
* <b>This should never be used</b>, except when preparing tables for migration.
* Pin a saved {@link DataSnapshot} by given version UUID, setting it's {@code pinned} state to {@code true}.
*
* @return A future returning void when complete
* @see Migrator#start()
* @param user The user to pin the data for
* @param versionUuid The UUID of the user's {@link DataSnapshot} entry to pin
*/
public abstract CompletableFuture<Void> wipeDatabase();
@Blocking
public final void pinSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
this.getSnapshot(user, versionUuid).ifPresent(data -> {
data.edit(plugin, (snapshot) -> snapshot.setPinned(true));
this.updateSnapshot(user, data);
});
}
/**
* Wipes <b>all</b> {@link User} entries from the database.
* <b>This should only be used when preparing tables for a data migration.</b>
*/
@Blocking
public abstract void wipeDatabase();
/**
* Close the database connection
*/
public abstract void close();
public abstract void terminate();
/**
* Identifies types of databases

View File

@@ -21,20 +21,17 @@ package net.william278.husksync.database;
import com.zaxxer.hikari.HikariDataSource;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataAdaptionException;
import net.william278.husksync.data.DataSaveCause;
import net.william278.husksync.data.UserData;
import net.william278.husksync.data.UserDataSnapshot;
import net.william278.husksync.event.DataSaveEvent;
import net.william278.husksync.player.User;
import net.william278.husksync.adapter.DataAdapter;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.NotNull;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.sql.*;
import java.util.Date;
import java.time.OffsetDateTime;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
public class MySqlDatabase extends Database {
@@ -46,8 +43,7 @@ public class MySqlDatabase extends Database {
public MySqlDatabase(@NotNull HuskSync plugin) {
super(plugin);
this.flavor = plugin.getSettings().getDatabaseType() == Type.MARIADB
? "mariadb" : "mysql";
this.flavor = plugin.getSettings().getDatabaseType().getProtocol();
this.driverClass = plugin.getSettings().getDatabaseType() == Type.MARIADB
? "org.mariadb.jdbc.Driver" : "com.mysql.cj.jdbc.Driver";
}
@@ -58,10 +54,16 @@ public class MySqlDatabase extends Database {
* @return The {@link Connection} to the MySQL database
* @throws SQLException if the connection fails for some reason
*/
@Blocking
@NotNull
private Connection getConnection() throws SQLException {
if (dataSource == null) {
throw new IllegalStateException("The database has not been initialized");
}
return dataSource.getConnection();
}
@Blocking
@Override
public void initialize() throws IllegalStateException {
// Initialize the Hikari pooled connection
@@ -124,192 +126,175 @@ public class MySqlDatabase extends Database {
}
}
@Blocking
@Override
public CompletableFuture<Void> ensureUser(@NotNull User user) {
return getUser(user.uuid).thenAccept(optionalUser ->
optionalUser.ifPresentOrElse(existingUser -> {
if (!existingUser.username.equals(user.username)) {
// Update a user's name if it has changed in the database
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
UPDATE `%users_table%`
SET `username`=?
WHERE `uuid`=?"""))) {
public void ensureUser(@NotNull User user) {
getUser(user.getUuid()).ifPresentOrElse(
existingUser -> {
if (!existingUser.getUsername().equals(user.getUsername())) {
// Update a user's name if it has changed in the database
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
UPDATE `%users_table%`
SET `username`=?
WHERE `uuid`=?"""))) {
statement.setString(1, user.username);
statement.setString(2, existingUser.uuid.toString());
statement.executeUpdate();
}
plugin.log(Level.INFO, "Updated " + user.username + "'s name in the database (" + existingUser.username + " -> " + user.username + ")");
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to update a user's name on the database", e);
}
statement.setString(1, user.getUsername());
statement.setString(2, existingUser.getUuid().toString());
statement.executeUpdate();
}
},
() -> {
// Insert new player data into the database
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
INSERT INTO `%users_table%` (`uuid`,`username`)
VALUES (?,?);"""))) {
plugin.log(Level.INFO, "Updated " + user.getUsername() + "'s name in the database (" + existingUser.getUsername() + " -> " + user.getUsername() + ")");
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to update a user's name on the database", e);
}
}
},
() -> {
// Insert new player data into the database
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
INSERT INTO `%users_table%` (`uuid`,`username`)
VALUES (?,?);"""))) {
statement.setString(1, user.uuid.toString());
statement.setString(2, user.username);
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to insert a user into the database", e);
}
}));
}
@Override
public CompletableFuture<Optional<User>> getUser(@NotNull UUID uuid) {
return CompletableFuture.supplyAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `uuid`, `username`
FROM `%users_table%`
WHERE `uuid`=?"""))) {
statement.setString(1, uuid.toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
return Optional.of(new User(UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username")));
statement.setString(1, user.getUuid().toString());
statement.setString(2, user.getUsername());
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to insert a user into the database", e);
}
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user from uuid from the database", e);
}
return Optional.empty();
});
);
}
@Blocking
@Override
public CompletableFuture<Optional<User>> getUserByName(@NotNull String username) {
return CompletableFuture.supplyAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `uuid`, `username`
FROM `%users_table%`
WHERE `username`=?"""))) {
statement.setString(1, username);
public Optional<User> getUser(@NotNull UUID uuid) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `uuid`, `username`
FROM `%users_table%`
WHERE `uuid`=?"""))) {
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
return Optional.of(new User(UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username")));
}
statement.setString(1, uuid.toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
return Optional.of(new User(UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username")));
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user by name from the database", e);
}
return Optional.empty();
});
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user from uuid from the database", e);
}
return Optional.empty();
}
@Blocking
@Override
public CompletableFuture<Optional<UserDataSnapshot>> getCurrentUserData(@NotNull User user) {
return CompletableFuture.supplyAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `save_cause`, `pinned`, `data`
FROM `%user_data_table%`
WHERE `player_uuid`=?
ORDER BY `timestamp` DESC
LIMIT 1;"""))) {
statement.setString(1, user.uuid.toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
final Blob blob = resultSet.getBlob("data");
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
blob.free();
return Optional.of(new UserDataSnapshot(
UUID.fromString(resultSet.getString("version_uuid")),
Date.from(resultSet.getTimestamp("timestamp").toInstant()),
DataSaveCause.getCauseByName(resultSet.getString("save_cause")),
resultSet.getBoolean("pinned"),
plugin.getDataAdapter().fromBytes(dataByteArray)));
}
public Optional<User> getUserByName(@NotNull String username) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `uuid`, `username`
FROM `%users_table%`
WHERE `username`=?"""))) {
statement.setString(1, username);
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
return Optional.of(new User(UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username")));
}
} catch (SQLException | DataAdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
}
return Optional.empty();
});
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user by name from the database", e);
}
return Optional.empty();
}
@Blocking
@Override
public CompletableFuture<List<UserDataSnapshot>> getUserData(@NotNull User user) {
return CompletableFuture.supplyAsync(() -> {
final List<UserDataSnapshot> retrievedData = new ArrayList<>();
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `save_cause`, `pinned`, `data`
FROM `%user_data_table%`
WHERE `player_uuid`=?
ORDER BY `timestamp` DESC;"""))) {
statement.setString(1, user.uuid.toString());
final ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
final Blob blob = resultSet.getBlob("data");
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
blob.free();
final UserDataSnapshot data = new UserDataSnapshot(
UUID.fromString(resultSet.getString("version_uuid")),
Date.from(resultSet.getTimestamp("timestamp").toInstant()),
DataSaveCause.getCauseByName(resultSet.getString("save_cause")),
resultSet.getBoolean("pinned"),
plugin.getDataAdapter().fromBytes(dataByteArray));
retrievedData.add(data);
}
return retrievedData;
public Optional<DataSnapshot.Packed> getLatestSnapshot(@NotNull User user) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `save_cause`, `pinned`, `data`
FROM `%user_data_table%`
WHERE `player_uuid`=?
ORDER BY `timestamp` DESC
LIMIT 1;"""))) {
statement.setString(1, user.getUuid().toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
final Blob blob = resultSet.getBlob("data");
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
blob.free();
return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray));
}
} catch (SQLException | DataAdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
}
return retrievedData;
});
} catch (SQLException | DataAdapter.AdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
}
return Optional.empty();
}
@Blocking
@Override
public CompletableFuture<Optional<UserDataSnapshot>> getUserData(@NotNull User user, @NotNull UUID versionUuid) {
return CompletableFuture.supplyAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `save_cause`, `pinned`, `data`
FROM `%user_data_table%`
WHERE `player_uuid`=? AND `version_uuid`=?
ORDER BY `timestamp` DESC
LIMIT 1;"""))) {
statement.setString(1, user.uuid.toString());
statement.setString(2, versionUuid.toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
final Blob blob = resultSet.getBlob("data");
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
blob.free();
return Optional.of(new UserDataSnapshot(
UUID.fromString(resultSet.getString("version_uuid")),
Date.from(resultSet.getTimestamp("timestamp").toInstant()),
DataSaveCause.getCauseByName(resultSet.getString("save_cause")),
resultSet.getBoolean("pinned"),
plugin.getDataAdapter().fromBytes(dataByteArray)));
}
@NotNull
public List<DataSnapshot.Packed> getAllSnapshots(@NotNull User user) {
final List<DataSnapshot.Packed> retrievedData = new ArrayList<>();
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `save_cause`, `pinned`, `data`
FROM `%user_data_table%`
WHERE `player_uuid`=?
ORDER BY `timestamp` DESC;"""))) {
statement.setString(1, user.getUuid().toString());
final ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
final Blob blob = resultSet.getBlob("data");
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
blob.free();
retrievedData.add(DataSnapshot.deserialize(plugin, dataByteArray));
}
} catch (SQLException | DataAdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch specific user data by UUID from the database", e);
return retrievedData;
}
return Optional.empty();
});
} catch (SQLException | DataAdapter.AdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
}
return retrievedData;
}
@Blocking
@Override
protected void rotateUserData(@NotNull User user) {
final List<UserDataSnapshot> unpinnedUserData = getUserData(user).join().stream()
.filter(dataSnapshot -> !dataSnapshot.pinned()).toList();
public Optional<DataSnapshot.Packed> getSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `save_cause`, `pinned`, `data`
FROM `%user_data_table%`
WHERE `player_uuid`=? AND `version_uuid`=?
ORDER BY `timestamp` DESC
LIMIT 1;"""))) {
statement.setString(1, user.getUuid().toString());
statement.setString(2, versionUuid.toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
final Blob blob = resultSet.getBlob("data");
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
blob.free();
return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray));
}
}
} catch (SQLException | DataAdapter.AdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch specific user data by UUID from the database", e);
}
return Optional.empty();
}
@Blocking
@Override
protected void rotateSnapshots(@NotNull User user) {
final List<DataSnapshot.Packed> unpinnedUserData = getAllSnapshots(user).stream()
.filter(dataSnapshot -> !dataSnapshot.isPinned()).toList();
if (unpinnedUserData.size() > plugin.getSettings().getMaxUserDataSnapshots()) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
@@ -319,7 +304,7 @@ public class MySqlDatabase extends Database {
ORDER BY `timestamp` ASC
LIMIT %entry_count%;""".replace("%entry_count%",
Integer.toString(unpinnedUserData.size() - plugin.getSettings().getMaxUserDataSnapshots()))))) {
statement.setString(1, user.uuid.toString());
statement.setString(1, user.getUuid().toString());
statement.executeUpdate();
}
} catch (SQLException e) {
@@ -328,105 +313,97 @@ public class MySqlDatabase extends Database {
}
}
@Blocking
@Override
public CompletableFuture<Boolean> deleteUserData(@NotNull User user, @NotNull UUID versionUuid) {
return CompletableFuture.supplyAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
DELETE FROM `%user_data_table%`
WHERE `player_uuid`=? AND `version_uuid`=?
LIMIT 1;"""))) {
statement.setString(1, user.uuid.toString());
statement.setString(2, versionUuid.toString());
return statement.executeUpdate() > 0;
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to delete specific user data from the database", e);
public boolean deleteSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
DELETE FROM `%user_data_table%`
WHERE `player_uuid`=? AND `version_uuid`=?
LIMIT 1;"""))) {
statement.setString(1, user.getUuid().toString());
statement.setString(2, versionUuid.toString());
return statement.executeUpdate() > 0;
}
return false;
});
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to delete specific user data from the database", e);
}
return false;
}
@Blocking
@Override
protected void rotateLatestSnapshot(@NotNull User user, @NotNull OffsetDateTime within) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
DELETE FROM `%user_data_table%`
WHERE `player_uuid`=? AND `timestamp`>? AND `pinned` IS FALSE
ORDER BY `timestamp` ASC
LIMIT 1;"""))) {
statement.setString(1, user.getUuid().toString());
statement.setTimestamp(2, Timestamp.from(within.toInstant()));
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to delete a user's data from the database", e);
}
}
@Blocking
@Override
protected void createSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed data) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
INSERT INTO `%user_data_table%`
(`player_uuid`,`version_uuid`,`timestamp`,`save_cause`,`pinned`,`data`)
VALUES (?,?,?,?,?,?);"""))) {
statement.setString(1, user.getUuid().toString());
statement.setString(2, data.getId().toString());
statement.setTimestamp(3, Timestamp.from(data.getTimestamp().toInstant()));
statement.setString(4, data.getSaveCause().name());
statement.setBoolean(5, data.isPinned());
statement.setBlob(6, new ByteArrayInputStream(data.asBytes(plugin)));
statement.executeUpdate();
}
} catch (SQLException | DataAdapter.AdaptionException e) {
plugin.log(Level.SEVERE, "Failed to set user data in the database", e);
}
}
@Blocking
@Override
public void updateSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed data) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
UPDATE `%user_data_table%`
SET `save_cause`=?,`pinned`=?,`data`=?
WHERE `player_uuid`=? AND `version_uuid`=?
LIMIT 1;"""))) {
statement.setString(1, data.getSaveCause().name());
statement.setBoolean(2, data.isPinned());
statement.setBlob(3, new ByteArrayInputStream(data.asBytes(plugin)));
statement.setString(4, user.getUuid().toString());
statement.setString(5, data.getId().toString());
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to pin user data in the database", e);
}
}
@Override
public CompletableFuture<Void> setUserData(@NotNull User user, @NotNull UserData userData,
@NotNull DataSaveCause saveCause) {
return CompletableFuture.runAsync(() -> {
final DataSaveEvent dataSaveEvent = (DataSaveEvent) plugin.getEventCannon().fireDataSaveEvent(user,
userData, saveCause).join();
if (!dataSaveEvent.isCancelled()) {
final UserData finalData = dataSaveEvent.getUserData();
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
INSERT INTO `%user_data_table%`
(`player_uuid`,`version_uuid`,`timestamp`,`save_cause`,`data`)
VALUES (?,UUID(),NOW(),?,?);"""))) {
statement.setString(1, user.uuid.toString());
statement.setString(2, saveCause.name());
statement.setBlob(3, new ByteArrayInputStream(
plugin.getDataAdapter().toBytes(finalData)));
statement.executeUpdate();
}
} catch (SQLException | DataAdaptionException e) {
plugin.log(Level.SEVERE, "Failed to set user data in the database", e);
}
public void wipeDatabase() {
try (Connection connection = getConnection()) {
try (Statement statement = connection.createStatement()) {
statement.executeUpdate(formatStatementTables("DELETE FROM `%user_data_table%`;"));
}
this.rotateUserData(user);
});
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to wipe the database", e);
}
}
@Override
public CompletableFuture<Void> pinUserData(@NotNull User user, @NotNull UUID versionUuid) {
return CompletableFuture.runAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
UPDATE `%user_data_table%`
SET `pinned`=TRUE
WHERE `player_uuid`=? AND `version_uuid`=?
LIMIT 1;"""))) {
statement.setString(1, user.uuid.toString());
statement.setString(2, versionUuid.toString());
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to pin user data in the database", e);
}
});
}
@Override
public CompletableFuture<Void> unpinUserData(@NotNull User user, @NotNull UUID versionUuid) {
return CompletableFuture.runAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
UPDATE `%user_data_table%`
SET `pinned`=FALSE
WHERE `player_uuid`=? AND `version_uuid`=?
LIMIT 1;"""))) {
statement.setString(1, user.uuid.toString());
statement.setString(2, versionUuid.toString());
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to unpin user data in the database", e);
}
});
}
@Override
public CompletableFuture<Void> wipeDatabase() {
return CompletableFuture.runAsync(() -> {
try (Connection connection = getConnection()) {
try (Statement statement = connection.createStatement()) {
statement.executeUpdate(formatStatementTables("DELETE FROM `%user_data_table%`;"));
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to wipe the database", e);
}
});
}
@Override
public void close() {
public void terminate() {
if (dataSource != null) {
if (!dataSource.isClosed()) {
dataSource.close();

View File

@@ -19,9 +19,9 @@
package net.william278.husksync.event;
public interface CancellableEvent extends Event {
@SuppressWarnings("unused")
public interface Cancellable extends Event {
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
default boolean isCancelled() {
return false;
}

View File

@@ -19,21 +19,34 @@
package net.william278.husksync.event;
import net.william278.husksync.data.DataSaveCause;
import net.william278.husksync.data.UserData;
import net.william278.husksync.player.User;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
public interface DataSaveEvent extends CancellableEvent {
import java.util.function.Consumer;
@SuppressWarnings("unused")
public interface DataSaveEvent extends Cancellable {
@NotNull
UserData getUserData();
void setUserData(@NotNull UserData userData);
@NotNull User getUser();
User getUser();
@NotNull
DataSaveCause getSaveCause();
DataSnapshot.Packed getData();
default void editData(@NotNull Consumer<DataSnapshot.Unpacked> editor) {
getData().edit(getPlugin(), editor);
}
@NotNull
default DataSnapshot.SaveCause getSaveCause() {
return getData().getSaveCause();
}
@NotNull
@ApiStatus.Internal
HuskSync getPlugin();
}

View File

@@ -19,10 +19,6 @@
package net.william278.husksync.event;
import java.util.concurrent.CompletableFuture;
public interface Event {
CompletableFuture<Event> fire();
}

View File

@@ -1,65 +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.event;
import net.william278.husksync.data.DataSaveCause;
import net.william278.husksync.data.UserData;
import net.william278.husksync.player.OnlineUser;
import net.william278.husksync.player.User;
import org.jetbrains.annotations.NotNull;
import java.util.concurrent.CompletableFuture;
/**
* Used to fire plugin {@link Event}s
*/
public abstract class EventCannon {
protected EventCannon() {
}
/**
* Fires a {@link PreSyncEvent}
*
* @param user The user to fire the event for
* @param userData The user data to fire the event with
* @return A future that will be completed when the event is fired
*/
public abstract CompletableFuture<Event> firePreSyncEvent(@NotNull OnlineUser user, @NotNull UserData userData);
/**
* Fires a {@link DataSaveEvent}
*
* @param user The user to fire the event for
* @param userData The user data to fire the event with
* @return A future that will be completed when the event is fired
*/
public abstract CompletableFuture<Event> fireDataSaveEvent(@NotNull User user, @NotNull UserData userData,
@NotNull DataSaveCause saveCause);
/**
* Fires a {@link SyncCompleteEvent}
*
* @param user The user to fire the event for
*/
public abstract void fireSyncCompleteEvent(@NotNull OnlineUser user);
}

View File

@@ -0,0 +1,72 @@
/*
* 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.event;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.function.Consumer;
/**
* Used to fire plugin {@link Event}s
*/
public interface EventDispatcher {
/**
* Fire an event synchronously, then run a callback asynchronously.
*
* @param event The event to fire
* @param callback The callback to run after the event has been fired
* @param <T> The material of event to fire
*/
default <T extends Event> void fireEvent(@NotNull T event, @Nullable Consumer<T> callback) {
getPlugin().runSync(() -> {
if (!fireIsCancelled(event) && callback != null) {
getPlugin().runAsync(() -> callback.accept(event));
}
});
}
/**
* Fire an event on this thread, and return whether the event was canceled.
*
* @param event The event to fire
* @param <T> The material of event to fire
* @return Whether the event was canceled
*/
<T extends Event> boolean fireIsCancelled(@NotNull T event);
@NotNull
PreSyncEvent getPreSyncEvent(@NotNull OnlineUser user, @NotNull DataSnapshot.Packed userData);
@NotNull
DataSaveEvent getDataSaveEvent(@NotNull User user, @NotNull DataSnapshot.Packed saveCause);
@NotNull
SyncCompleteEvent getSyncCompleteEvent(@NotNull OnlineUser user);
@NotNull
HuskSync getPlugin();
}

View File

@@ -19,10 +19,12 @@
package net.william278.husksync.event;
import net.william278.husksync.player.OnlineUser;
import net.william278.husksync.user.OnlineUser;
import org.jetbrains.annotations.NotNull;
public interface PlayerEvent extends Event {
@NotNull
OnlineUser getUser();
}

View File

@@ -19,14 +19,30 @@
package net.william278.husksync.event;
import net.william278.husksync.data.UserData;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSnapshot;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
public interface PreSyncEvent extends CancellableEvent {
import java.util.function.Consumer;
@SuppressWarnings("unused")
public interface PreSyncEvent extends PlayerEvent {
@NotNull
UserData getUserData();
DataSnapshot.Packed getData();
void setUserData(@NotNull UserData userData);
default void editData(@NotNull Consumer<DataSnapshot.Unpacked> editor) {
getData().edit(getPlugin(), editor);
}
@NotNull
default DataSnapshot.SaveCause getSaveCause() {
return getData().getSaveCause();
}
@NotNull
@ApiStatus.Internal
HuskSync getPlugin();
}

View File

@@ -1,240 +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.hook;
import com.djrapitops.plan.extension.CallEvents;
import com.djrapitops.plan.extension.DataExtension;
import com.djrapitops.plan.extension.ElementOrder;
import com.djrapitops.plan.extension.FormatType;
import com.djrapitops.plan.extension.annotation.*;
import com.djrapitops.plan.extension.icon.Color;
import com.djrapitops.plan.extension.icon.Family;
import com.djrapitops.plan.extension.icon.Icon;
import com.djrapitops.plan.extension.table.Table;
import com.djrapitops.plan.extension.table.TableColumnFormat;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.UserDataSnapshot;
import net.william278.husksync.player.User;
import org.jetbrains.annotations.NotNull;
import java.util.Date;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Pattern;
@TabInfo(
tab = "Current Status",
iconName = "id-card",
iconFamily = Family.SOLID,
elementOrder = {ElementOrder.VALUES, ElementOrder.TABLE, ElementOrder.GRAPH}
)
@TabInfo(
tab = "Data Snapshots",
iconName = "clipboard-list",
iconFamily = Family.SOLID,
elementOrder = {ElementOrder.VALUES, ElementOrder.TABLE, ElementOrder.GRAPH}
)
@TabOrder({"Current Status", "Data Snapshots"})
@PluginInfo(
name = "HuskSync",
iconName = "exchange-alt",
iconFamily = Family.SOLID,
color = Color.LIGHT_BLUE
)
@SuppressWarnings("unused")
public class PlanDataExtension implements DataExtension {
private HuskSync plugin;
private static final String UNKNOWN_STRING = "N/A";
private static final String PINNED_HTML_STRING = "&#128205;&nbsp;";
protected PlanDataExtension(@NotNull HuskSync plugin) {
this.plugin = plugin;
}
protected PlanDataExtension() {
}
@Override
public CallEvents[] callExtensionMethodsOn() {
return new CallEvents[]{
CallEvents.PLAYER_JOIN,
CallEvents.PLAYER_LEAVE
};
}
private CompletableFuture<Optional<UserDataSnapshot>> getCurrentUserData(@NotNull UUID uuid) {
return CompletableFuture.supplyAsync(() -> {
final Optional<User> optionalUser = plugin.getDatabase().getUser(uuid).join();
if (optionalUser.isPresent()) {
return plugin.getDatabase().getCurrentUserData(optionalUser.get()).join();
}
return Optional.empty();
});
}
@BooleanProvider(
text = "Has Synced",
description = "Whether this user has saved, synchronised data.",
iconName = "exchange-alt",
iconFamily = Family.SOLID,
conditionName = "hasSynced",
hidden = true
)
@Tab("Current Status")
public boolean getUserHasSynced(@NotNull UUID uuid) {
return getCurrentUserData(uuid).join().isPresent();
}
@Conditional("hasSynced")
@NumberProvider(
text = "Sync Time",
description = "The last time the user had their data synced with the server.",
iconName = "clock",
iconFamily = Family.SOLID,
format = FormatType.DATE_SECOND,
priority = 6
)
@Tab("Current Status")
public long getCurrentDataTimestamp(@NotNull UUID uuid) {
return getCurrentUserData(uuid).join().map(
versionedUserData -> versionedUserData.versionTimestamp().getTime())
.orElse(new Date().getTime());
}
@Conditional("hasSynced")
@StringProvider(
text = "Version ID",
description = "ID of the data version that the user is currently using.",
iconName = "bolt",
iconFamily = Family.SOLID,
priority = 5
)
@Tab("Current Status")
public String getCurrentDataId(@NotNull UUID uuid) {
return getCurrentUserData(uuid).join()
.map(versionedUserData -> versionedUserData.versionUUID().toString()
.split(Pattern.quote("-"))[0])
.orElse(UNKNOWN_STRING);
}
@Conditional("hasSynced")
@StringProvider(
text = "Health",
description = "The number of health points out of the max health points this player currently has.",
iconName = "heart",
iconFamily = Family.SOLID,
priority = 4
)
@Tab("Current Status")
public String getHealth(@NotNull UUID uuid) {
return getCurrentUserData(uuid).join()
.flatMap(versionedUserData -> versionedUserData.userData().getStatus())
.map(statusData -> (int) statusData.health + "/" + (int) statusData.maxHealth)
.orElse(UNKNOWN_STRING);
}
@Conditional("hasSynced")
@NumberProvider(
text = "Hunger",
description = "The number of hunger points this player currently has.",
iconName = "drumstick-bite",
iconFamily = Family.SOLID,
priority = 3
)
@Tab("Current Status")
public long getHunger(@NotNull UUID uuid) {
return getCurrentUserData(uuid).join()
.flatMap(versionedUserData -> versionedUserData.userData().getStatus())
.map(statusData -> (long) statusData.hunger)
.orElse(0L);
}
@Conditional("hasSynced")
@NumberProvider(
text = "Experience Level",
description = "The number of experience levels this player currently has.",
iconName = "hat-wizard",
iconFamily = Family.SOLID,
priority = 2
)
@Tab("Current Status")
public long getExperienceLevel(@NotNull UUID uuid) {
return getCurrentUserData(uuid).join()
.flatMap(versionedUserData -> versionedUserData.userData().getStatus())
.map(statusData -> (long) statusData.expLevel)
.orElse(0L);
}
@Conditional("hasSynced")
@StringProvider(
text = "Game Mode",
description = "The game mode this player is currently in.",
iconName = "gamepad",
iconFamily = Family.SOLID,
priority = 1
)
@Tab("Current Status")
public String getGameMode(@NotNull UUID uuid) {
return getCurrentUserData(uuid).join()
.flatMap(versionedUserData -> versionedUserData.userData().getStatus())
.map(status -> status.gameMode)
.orElse(UNKNOWN_STRING);
}
@Conditional("hasSynced")
@NumberProvider(
text = "Advancements",
description = "The number of advancements & recipes the player has progressed in.",
iconName = "award",
iconFamily = Family.SOLID
)
@Tab("Current Status")
public long getAdvancementsCompleted(@NotNull UUID playerUUID) {
return getCurrentUserData(playerUUID).join()
.flatMap(versionedUserData -> versionedUserData.userData().getAdvancements())
.map(advancementsData -> (long) advancementsData.size())
.orElse(0L);
}
@Conditional("hasSynced")
@TableProvider(tableColor = Color.LIGHT_BLUE)
@Tab("Data Snapshots")
public Table getDataSnapshots(@NotNull UUID playerUUID) {
final Table.Factory dataSnapshotsTable = Table.builder()
.columnOne("Time", new Icon(Family.SOLID, "clock", Color.NONE))
.columnOneFormat(TableColumnFormat.DATE_SECOND)
.columnTwo("ID", new Icon(Family.SOLID, "bolt", Color.NONE))
.columnThree("Cause", new Icon(Family.SOLID, "flag", Color.NONE))
.columnFour("Pinned", new Icon(Family.SOLID, "thumbtack", Color.NONE));
plugin.getDatabase().getUser(playerUUID).join().ifPresent(user ->
plugin.getDatabase().getUserData(user).join().forEach(versionedUserData -> dataSnapshotsTable.addRow(
versionedUserData.versionTimestamp().getTime(),
versionedUserData.versionUUID().toString().split("-")[0],
versionedUserData.cause().name().toLowerCase(Locale.ENGLISH).replaceAll("_", " "),
versionedUserData.pinned() ? PINNED_HTML_STRING + "Pinned" : "Unpinned"
)));
return dataSnapshotsTable.build();
}
}

View File

@@ -20,10 +20,22 @@
package net.william278.husksync.hook;
import com.djrapitops.plan.capability.CapabilityService;
import com.djrapitops.plan.extension.ExtensionService;
import com.djrapitops.plan.extension.*;
import com.djrapitops.plan.extension.annotation.*;
import com.djrapitops.plan.extension.icon.Color;
import com.djrapitops.plan.extension.icon.Family;
import com.djrapitops.plan.extension.icon.Icon;
import com.djrapitops.plan.extension.table.Table;
import com.djrapitops.plan.extension.table.TableColumnFormat;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataHolder;
import net.william278.husksync.data.DataSnapshot;
import org.jetbrains.annotations.NotNull;
import java.time.OffsetDateTime;
import java.util.Optional;
import java.util.UUID;
import java.util.logging.Level;
public class PlanHook {
@@ -50,6 +62,7 @@ public class PlanHook {
private void registerDataExtension() {
try {
ExtensionService.getInstance().register(new PlanDataExtension(plugin));
plugin.log(Level.INFO, "Registered HuskSync Plan data extension");
} catch (IllegalStateException | IllegalArgumentException e) {
plugin.log(Level.WARNING, "Failed to register Plan data extension: " + e.getMessage(), e);
}
@@ -64,4 +77,199 @@ public class PlanHook {
});
}
@TabInfo(
tab = "Current Status",
iconName = "id-card",
iconFamily = Family.SOLID,
elementOrder = {ElementOrder.VALUES, ElementOrder.TABLE, ElementOrder.GRAPH}
)
@TabInfo(
tab = "Data Snapshots",
iconName = "clipboard-list",
iconFamily = Family.SOLID,
elementOrder = {ElementOrder.VALUES, ElementOrder.TABLE, ElementOrder.GRAPH}
)
@TabOrder({"Current Status", "Data Snapshots"})
@PluginInfo(
name = "HuskSync",
iconName = "exchange-alt",
iconFamily = Family.SOLID,
color = Color.LIGHT_BLUE
)
@SuppressWarnings("unused")
public static class PlanDataExtension implements DataExtension {
private HuskSync plugin;
private static final String UNKNOWN_STRING = "N/A";
private static final String PINNED_HTML_STRING = "&#128205;&nbsp;";
protected PlanDataExtension(@NotNull HuskSync plugin) {
this.plugin = plugin;
}
protected PlanDataExtension() {
}
@Override
public CallEvents[] callExtensionMethodsOn() {
return new CallEvents[]{
CallEvents.PLAYER_JOIN,
CallEvents.PLAYER_LEAVE
};
}
// Get the user's latest data snapshot
private Optional<DataSnapshot.Unpacked> getLatestSnapshot(@NotNull UUID uuid) {
return plugin.getDatabase().getUser(uuid)
.flatMap(user -> plugin.getDatabase().getLatestSnapshot(user))
.map(snapshot -> snapshot.unpack(plugin));
}
@BooleanProvider(
text = "Has Synced",
description = "Whether this user has saved, synchronized data.",
iconName = "exchange-alt",
iconFamily = Family.SOLID,
conditionName = "hasSynced",
hidden = true
)
@Tab("Current Status")
public boolean getUserHasSynced(@NotNull UUID uuid) {
return getLatestSnapshot(uuid).isPresent();
}
@Conditional("hasSynced")
@NumberProvider(
text = "Sync Time",
description = "The last time the user had their data synced with the server.",
iconName = "clock",
iconFamily = Family.SOLID,
format = FormatType.DATE_SECOND,
priority = 6
)
@Tab("Current Status")
public long getCurrentDataTimestamp(@NotNull UUID uuid) {
return getLatestSnapshot(uuid)
.map(DataSnapshot::getTimestamp)
.orElse(OffsetDateTime.now())
.toEpochSecond();
}
@Conditional("hasSynced")
@StringProvider(
text = "Version ID",
description = "ID of the data version that the user is currently using.",
iconName = "bolt",
iconFamily = Family.SOLID,
priority = 5
)
@Tab("Current Status")
public String getCurrentDataId(@NotNull UUID uuid) {
return getLatestSnapshot(uuid)
.map(DataSnapshot::getShortId)
.orElse(UNKNOWN_STRING);
}
@Conditional("hasSynced")
@StringProvider(
text = "Health",
description = "The number of health points out of the max health points this player currently has.",
iconName = "heart",
iconFamily = Family.SOLID,
priority = 4
)
@Tab("Current Status")
public String getHealth(@NotNull UUID uuid) {
return getLatestSnapshot(uuid)
.flatMap(DataHolder::getHealth)
.map(health -> String.format("%s / %s", health.getHealth(), health.getMaxHealth()))
.orElse(UNKNOWN_STRING);
}
@Conditional("hasSynced")
@NumberProvider(
text = "Hunger",
description = "The number of hunger points this player currently has.",
iconName = "drumstick-bite",
iconFamily = Family.SOLID,
priority = 3
)
@Tab("Current Status")
public long getHunger(@NotNull UUID uuid) {
return getLatestSnapshot(uuid)
.flatMap(DataHolder::getHunger)
.map(Data.Hunger::getFoodLevel)
.orElse(20);
}
@Conditional("hasSynced")
@NumberProvider(
text = "Experience Level",
description = "The number of experience levels this player currently has.",
iconName = "hat-wizard",
iconFamily = Family.SOLID,
priority = 2
)
@Tab("Current Status")
public long getExperienceLevel(@NotNull UUID uuid) {
return getLatestSnapshot(uuid)
.flatMap(DataHolder::getExperience)
.map(Data.Experience::getExpLevel)
.orElse(0);
}
@Conditional("hasSynced")
@StringProvider(
text = "Game Mode",
description = "The game mode this player is currently in.",
iconName = "gamepad",
iconFamily = Family.SOLID,
priority = 1
)
@Tab("Current Status")
public String getGameMode(@NotNull UUID uuid) {
return getLatestSnapshot(uuid)
.flatMap(DataHolder::getGameMode)
.map(Data.GameMode::getGameMode)
.orElse(UNKNOWN_STRING);
}
@Conditional("hasSynced")
@NumberProvider(
text = "Advancements",
description = "The number of advancements & recipes the player has progressed in.",
iconName = "award",
iconFamily = Family.SOLID
)
@Tab("Current Status")
public long getAdvancementsCompleted(@NotNull UUID playerUUID) {
return getLatestSnapshot(playerUUID)
.flatMap(DataHolder::getAdvancements)
.map(Data.Advancements::getCompleted)
.stream().count();
}
@Conditional("hasSynced")
@TableProvider(tableColor = Color.LIGHT_BLUE)
@Tab("Data Snapshots")
public Table getDataSnapshots(@NotNull UUID playerUUID) {
final Table.Factory dataSnapshotsTable = Table.builder()
.columnOne("Time", new Icon(Family.SOLID, "clock", Color.NONE))
.columnOneFormat(TableColumnFormat.DATE_SECOND)
.columnTwo("ID", new Icon(Family.SOLID, "bolt", Color.NONE))
.columnThree("Cause", new Icon(Family.SOLID, "flag", Color.NONE))
.columnFour("Pinned", new Icon(Family.SOLID, "thumbtack", Color.NONE));
plugin.getDatabase().getUser(playerUUID).ifPresent(user ->
plugin.getDatabase().getAllSnapshots(user).forEach(snapshot -> dataSnapshotsTable.addRow(
snapshot.getTimestamp().toEpochSecond(),
snapshot.getShortId(),
snapshot.getSaveCause().getDisplayName(),
snapshot.isPinned() ? PINNED_HTML_STRING + "Pinned" : "Unpinned"
))
);
return dataSnapshotsTable.build();
}
}
}

View File

@@ -19,22 +19,16 @@
package net.william278.husksync.listener;
import de.themoep.minedown.adventure.MineDown;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSaveCause;
import net.william278.husksync.data.ItemData;
import net.william278.husksync.player.OnlineUser;
import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.util.Task;
import org.jetbrains.annotations.NotNull;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
/**
@@ -42,13 +36,11 @@ import java.util.logging.Level;
*/
public abstract class EventListener {
/**
* The plugin instance
*/
// The plugin instance
protected final HuskSync plugin;
/**
* Set of UUIDs of "locked players", for which events will be cancelled.
* 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)
*/
@@ -66,7 +58,7 @@ public abstract class EventListener {
}
/**
* Handle a player joining the server (including players switching from another proxied server)
* Handle a player joining the server (including players switching from another server on the network)
*
* @param user The {@link OnlineUser} to handle
*/
@@ -74,92 +66,51 @@ public abstract class EventListener {
if (user.isNpc()) {
return;
}
lockedPlayers.add(user.getUuid());
lockedPlayers.add(user.uuid);
CompletableFuture.runAsync(() -> {
try {
// Hold reading data for the network latency threshold, to ensure the source server has set the redis key
Thread.sleep(Math.max(0, plugin.getSettings().getNetworkLatencyMilliseconds()));
} catch (InterruptedException e) {
plugin.log(Level.SEVERE, "An exception occurred handling a player join", e);
} finally {
plugin.getRedisManager().getUserServerSwitch(user).thenAccept(changingServers -> {
if (!changingServers) {
// Fetch from the database if the user isn't changing servers
setUserFromDatabase(user).thenAccept(succeeded -> handleSynchronisationCompletion(user, succeeded));
} else {
final int TIME_OUT_MILLISECONDS = 3200;
CompletableFuture.runAsync(() -> {
final AtomicInteger currentMilliseconds = new AtomicInteger(0);
final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
// Set the user as soon as the source server has set the data to redis
executor.scheduleAtFixedRate(() -> {
if (user.isOffline()) {
executor.shutdown();
return;
}
if (disabling || currentMilliseconds.get() > TIME_OUT_MILLISECONDS) {
executor.shutdown();
setUserFromDatabase(user).thenAccept(
succeeded -> handleSynchronisationCompletion(user, succeeded));
return;
}
plugin.getRedisManager().getUserData(user).thenAccept(redisUserData ->
redisUserData.ifPresent(redisData -> {
user.setData(redisData, plugin)
.thenAccept(succeeded -> handleSynchronisationCompletion(user, succeeded)).join();
executor.shutdown();
})).join();
currentMilliseconds.addAndGet(200);
}, 0, 200L, TimeUnit.MILLISECONDS);
});
}
});
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
* @return Whether the data was successfully set
*/
private CompletableFuture<Boolean> setUserFromDatabase(@NotNull OnlineUser user) {
return plugin.getDatabase().getCurrentUserData(user).thenApply(databaseUserData -> {
if (databaseUserData.isPresent()) {
return user.setData(databaseUserData.get().userData(), plugin).join();
}
return true;
});
}
/**
* Handle a player's synchronization completion
*
* @param user The {@link OnlineUser} to handle
* @param succeeded Whether the synchronization succeeded
*/
private void handleSynchronisationCompletion(@NotNull OnlineUser user, boolean succeeded) {
if (succeeded) {
switch (plugin.getSettings().getNotificationDisplaySlot()) {
case CHAT -> plugin.getLocales().getLocale("synchronisation_complete")
.ifPresent(user::sendMessage);
case ACTION_BAR -> plugin.getLocales().getLocale("synchronisation_complete")
.ifPresent(user::sendActionBar);
case TOAST -> plugin.getLocales().getLocale("synchronisation_complete")
.ifPresent(locale -> user.sendToast(locale, new MineDown(""),
"minecraft:bell", "TASK"));
}
plugin.getDatabase().ensureUser(user).join();
lockedPlayers.remove(user.uuid);
plugin.getEventCannon().fireSyncCompleteEvent(user);
} else {
plugin.getLocales().getLocale("synchronisation_failed")
.ifPresent(user::sendMessage);
plugin.getDatabase().ensureUser(user).join();
}
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)
);
}
/**
@@ -168,28 +119,27 @@ public abstract class EventListener {
* @param user The {@link OnlineUser} to handle
*/
protected final void handlePlayerQuit(@NotNull OnlineUser user) {
// Players quitting have their data manually saved by the plugin disable hook
// Players quitting have their data manually saved when the plugin is disabled
if (disabling) {
return;
}
// Don't sync players awaiting synchronization
if (lockedPlayers.contains(user.uuid) || user.isNpc()) {
if (lockedPlayers.contains(user.getUuid()) || user.isNpc()) {
return;
}
// Handle asynchronous disconnection
lockedPlayers.add(user.uuid);
CompletableFuture.runAsync(() -> plugin.getRedisManager().setUserServerSwitch(user)
.thenRun(() -> user.getUserData(plugin).thenAccept(
optionalUserData -> optionalUserData.ifPresent(userData -> plugin.getRedisManager()
.setUserData(user, userData).thenRun(() -> plugin.getDatabase()
.setUserData(user, userData, DataSaveCause.DISCONNECT)))))
.exceptionally(throwable -> {
plugin.log(Level.SEVERE,
"An exception occurred handling a player disconnection");
throwable.printStackTrace();
return null;
}).join());
// 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);
}
}
/**
@@ -202,10 +152,10 @@ public abstract class EventListener {
return;
}
usersInWorld.stream()
.filter(user -> !lockedPlayers.contains(user.uuid) && !user.isNpc())
.forEach(user -> user.getUserData(plugin)
.thenAccept(data -> data.ifPresent(userData -> plugin.getDatabase()
.setUserData(user, userData, DataSaveCause.WORLD_SAVE))));
.filter(user -> !lockedPlayers.contains(user.getUuid()) && !user.isNpc())
.forEach(user -> plugin.getDatabase().addSnapshot(
user, user.createSnapshot(DataSnapshot.SaveCause.WORLD_SAVE)
));
}
/**
@@ -214,24 +164,22 @@ public abstract class EventListener {
* @param user The user who died
* @param drops The items that this user would have dropped
*/
protected void saveOnPlayerDeath(@NotNull OnlineUser user, @NotNull ItemData drops) {
if (disabling || !plugin.getSettings().doSaveOnDeath() || lockedPlayers.contains(user.uuid) || user.isNpc()
|| (!plugin.getSettings().doSaveEmptyDropsOnDeath() && drops.isEmpty())) {
protected void saveOnPlayerDeath(@NotNull OnlineUser user, @NotNull Data.Items drops) {
if (disabling || !plugin.getSettings().doSaveOnDeath() || lockedPlayers.contains(user.getUuid()) || user.isNpc()
|| (!plugin.getSettings().doSaveEmptyDropsOnDeath() && drops.isEmpty())) {
return;
}
user.getUserData(plugin)
.thenAccept(data -> data.ifPresent(userData -> {
userData.getInventory().orElse(ItemData.empty()).serializedItems = drops.serializedItems;
plugin.getDatabase().setUserData(user, userData, DataSaveCause.DEATH);
}));
final DataSnapshot.Packed snapshot = user.createSnapshot(DataSnapshot.SaveCause.DEATH);
snapshot.edit(plugin, (data -> data.getInventory().ifPresent(inventory -> inventory.setContents(drops))));
plugin.getDatabase().addSnapshot(user, snapshot);
}
/**
* Determine whether a player event should be cancelled
* Determine whether a player event should be canceled
*
* @param userUuid The UUID of the user to check
* @return Whether the event should be cancelled
* @return Whether the event should be canceled
*/
protected final boolean cancelPlayerEvent(@NotNull UUID userUuid) {
return disabling || lockedPlayers.contains(userUuid);
@@ -245,21 +193,65 @@ public abstract class EventListener {
// Save data for all online users
plugin.getOnlineUsers().stream()
.filter(user -> !lockedPlayers.contains(user.uuid) && !user.isNpc())
.filter(user -> !lockedPlayers.contains(user.getUuid()) && !user.isNpc())
.forEach(user -> {
lockedPlayers.add(user.uuid);
user.getUserData(plugin).join()
.ifPresent(userData -> plugin.getDatabase()
.setUserData(user, userData, DataSaveCause.SERVER_SHUTDOWN).join());
lockedPlayers.add(user.getUuid());
plugin.getDatabase().addSnapshot(user, user.createSnapshot(DataSnapshot.SaveCause.SERVER_SHUTDOWN));
});
// Close outstanding connections
plugin.getDatabase().close();
plugin.getRedisManager().close();
plugin.getDatabase().terminate();
plugin.getRedisManager().terminate();
}
public final Set<UUID> getLockedPlayers() {
return this.lockedPlayers;
}
/**
* Represents priorities for events that HuskSync listens to
*/
public enum Priority {
/**
* Listens and processes the event execution last
*/
HIGHEST,
/**
* Listens in between {@link #HIGHEST} and {@link #LOWEST} priority marked
*/
NORMAL,
/**
* Listens and processes the event execution first
*/
LOWEST
}
/**
* Represents events that HuskSync listens to, with a configurable priority listener
*/
public enum ListenerType {
JOIN_LISTENER(Priority.LOWEST),
QUIT_LISTENER(Priority.LOWEST),
DEATH_LISTENER(Priority.NORMAL);
private final Priority defaultPriority;
ListenerType(@NotNull EventListener.Priority defaultPriority) {
this.defaultPriority = defaultPriority;
}
@NotNull
private Map.Entry<String, String> toEntry() {
return Map.entry(name().toLowerCase(), defaultPriority.name());
}
@SuppressWarnings("unchecked")
@NotNull
public static Map<String, String> getDefaults() {
return Map.ofEntries(Arrays.stream(values())
.map(ListenerType::toEntry)
.toArray(Map.Entry[]::new));
}
}
}

View File

@@ -20,13 +20,12 @@
package net.william278.husksync.migrator;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.UserData;
import org.jetbrains.annotations.NotNull;
import java.util.concurrent.CompletableFuture;
/**
* A migrator that migrates data from other data formats to HuskSync's {@link UserData} format
* A migrator that migrates data from other data formats to HuskSync's format
*/
public abstract class Migrator {

View File

@@ -1,423 +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.player;
import de.themoep.minedown.adventure.MineDown;
import de.themoep.minedown.adventure.MineDownParser;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.text.Component;
import net.william278.desertwell.util.Version;
import net.william278.husksync.HuskSync;
import net.william278.husksync.config.Settings;
import net.william278.husksync.data.*;
import net.william278.husksync.event.PreSyncEvent;
import org.jetbrains.annotations.NotNull;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
/**
* Represents a logged-in {@link User}
*/
public abstract class OnlineUser extends User {
public OnlineUser(@NotNull UUID uuid, @NotNull String username) {
super(uuid, username);
}
/**
* Get the player's {@link StatusData}
*
* @return the player's {@link StatusData}
*/
public abstract CompletableFuture<StatusData> getStatus();
/**
* Set the player's {@link StatusData}
*
* @param statusData the player's {@link StatusData}
* @param statusDataFlags the flags to use for setting the status data
* @return a future returning void when complete
* @deprecated Use {@link #setStatus(StatusData, Settings)} instead
*/
@Deprecated(since = "2.1")
public final CompletableFuture<Void> setStatus(@NotNull StatusData statusData,
@NotNull List<StatusDataFlag> statusDataFlags) {
final Settings settings = new Settings();
settings.getSynchronizationFeatures().put(Settings.SynchronizationFeature.HEALTH.name().toLowerCase(Locale.ENGLISH), statusDataFlags.contains(StatusDataFlag.SET_HEALTH));
settings.getSynchronizationFeatures().put(Settings.SynchronizationFeature.MAX_HEALTH.name().toLowerCase(Locale.ENGLISH), statusDataFlags.contains(StatusDataFlag.SET_MAX_HEALTH));
settings.getSynchronizationFeatures().put(Settings.SynchronizationFeature.HUNGER.name().toLowerCase(Locale.ENGLISH), statusDataFlags.contains(StatusDataFlag.SET_HUNGER));
settings.getSynchronizationFeatures().put(Settings.SynchronizationFeature.EXPERIENCE.name().toLowerCase(Locale.ENGLISH), statusDataFlags.contains(StatusDataFlag.SET_EXPERIENCE));
settings.getSynchronizationFeatures().put(Settings.SynchronizationFeature.INVENTORIES.name().toLowerCase(Locale.ENGLISH), statusDataFlags.contains(StatusDataFlag.SET_SELECTED_ITEM_SLOT));
settings.getSynchronizationFeatures().put(Settings.SynchronizationFeature.LOCATION.name().toLowerCase(Locale.ENGLISH), statusDataFlags.contains(StatusDataFlag.SET_GAME_MODE) || statusDataFlags.contains(StatusDataFlag.SET_FLYING));
return setStatus(statusData, settings);
}
/**
* Set the player's {@link StatusData}
*
* @param statusData the player's {@link StatusData}
* @param settings settings, containing information about which features should be synced
* @return a future returning void when complete
*/
public abstract CompletableFuture<Void> setStatus(@NotNull StatusData statusData, @NotNull Settings settings);
/**
* Get the player's inventory {@link ItemData} contents
*
* @return The player's inventory {@link ItemData} contents
*/
public abstract CompletableFuture<ItemData> getInventory();
/**
* Set the player's {@link ItemData}
*
* @param itemData The player's {@link ItemData}
* @return a future returning void when complete
*/
public abstract CompletableFuture<Void> setInventory(@NotNull ItemData itemData);
/**
* Get the player's ender chest {@link ItemData} contents
*
* @return The player's ender chest {@link ItemData} contents
*/
public abstract CompletableFuture<ItemData> getEnderChest();
/**
* Set the player's {@link ItemData}
*
* @param enderChestData The player's {@link ItemData}
* @return a future returning void when complete
*/
public abstract CompletableFuture<Void> setEnderChest(@NotNull ItemData enderChestData);
/**
* Get the player's {@link PotionEffectData}
*
* @return The player's {@link PotionEffectData}
*/
public abstract CompletableFuture<PotionEffectData> getPotionEffects();
/**
* Set the player's {@link PotionEffectData}
*
* @param potionEffectData The player's {@link PotionEffectData}
* @return a future returning void when complete
*/
public abstract CompletableFuture<Void> setPotionEffects(@NotNull PotionEffectData potionEffectData);
/**
* Get the player's set of {@link AdvancementData}
*
* @return the player's set of {@link AdvancementData}
*/
public abstract CompletableFuture<List<AdvancementData>> getAdvancements();
/**
* Set the player's {@link AdvancementData}
*
* @param advancementData List of the player's {@link AdvancementData}
* @return a future returning void when complete
*/
public abstract CompletableFuture<Void> setAdvancements(@NotNull List<AdvancementData> advancementData);
/**
* Get the player's {@link StatisticsData}
*
* @return The player's {@link StatisticsData}
*/
public abstract CompletableFuture<StatisticsData> getStatistics();
/**
* Set the player's {@link StatisticsData}
*
* @param statisticsData The player's {@link StatisticsData}
* @return a future returning void when complete
*/
public abstract CompletableFuture<Void> setStatistics(@NotNull StatisticsData statisticsData);
/**
* Get the player's {@link LocationData}
*
* @return the player's {@link LocationData}
*/
public abstract CompletableFuture<LocationData> getLocation();
/**
* Set the player's {@link LocationData}
*
* @param locationData the player's {@link LocationData}
* @return a future returning void when complete
*/
public abstract CompletableFuture<Void> setLocation(@NotNull LocationData locationData);
/**
* Get the player's {@link PersistentDataContainerData}
*
* @return The player's {@link PersistentDataContainerData} when fetched
*/
public abstract CompletableFuture<PersistentDataContainerData> getPersistentDataContainer();
/**
* Set the player's {@link PersistentDataContainerData}
*
* @param persistentDataContainerData The player's {@link PersistentDataContainerData} to set
* @return A future returning void when complete
*/
public abstract CompletableFuture<Void> setPersistentDataContainer(@NotNull PersistentDataContainerData persistentDataContainerData);
/**
* Indicates if the player has gone offline
*
* @return {@code true} if the player has left the server; {@code false} otherwise
*/
public abstract boolean isOffline();
/**
* Returns the implementing Minecraft server version
*
* @return The Minecraft server version
*/
@NotNull
public abstract Version getMinecraftVersion();
/**
* Get the player's adventure {@link Audience}
*
* @return the player's {@link Audience}
*/
@NotNull
public abstract Audience getAudience();
/**
* Send a message to this player
*
* @param component the {@link Component} message to send
*/
public void sendMessage(@NotNull Component component) {
getAudience().sendMessage(component);
}
/**
* Dispatch a MineDown-formatted message to this player
*
* @param mineDown the parsed {@link MineDown} to send
*/
public void sendMessage(@NotNull MineDown mineDown) {
sendMessage(mineDown
.disable(MineDownParser.Option.SIMPLE_FORMATTING)
.replace().toComponent());
}
/**
* Dispatch a MineDown-formatted action bar message to this player
*
* @param mineDown the parsed {@link MineDown} to send
*/
public void sendActionBar(@NotNull MineDown mineDown) {
getAudience().sendActionBar(mineDown
.disable(MineDownParser.Option.SIMPLE_FORMATTING)
.replace().toComponent());
}
/**
* Dispatch a toast message to this player
*
* @param title the title of the toast
* @param description the description of the toast
* @param iconMaterial the namespace-keyed material to use as an icon of the toast
* @param backgroundType the background ("ToastType") of the toast
*/
public abstract void sendToast(@NotNull MineDown title, @NotNull MineDown description,
@NotNull String iconMaterial, @NotNull String backgroundType);
/**
* Returns if the player has the permission node
*
* @param node The permission node string
* @return {@code true} if the player has permission node; {@code false} otherwise
*/
public abstract boolean hasPermission(@NotNull String node);
/**
* Show a GUI chest menu to the player, containing the given {@link ItemData}
*
* @param itemData Item data to be shown in the GUI
* @param editable If the player should be able to remove, replace and move around the items
* @param minimumRows The minimum number of rows to show in the chest menu
* @param title The title of the chest menu, as a {@link MineDown} locale
* @return A future returning the {@link ItemData} in the chest menu when the player closes it
* @since 2.1
*/
public abstract CompletableFuture<Optional<ItemData>> showMenu(@NotNull ItemData itemData, boolean editable,
int minimumRows, @NotNull MineDown title);
/**
* Returns true if the player is dead
*
* @return true if the player is dead
*/
public abstract boolean isDead();
/**
* Apply {@link UserData} to a player, updating their inventory, status, statistics, etc. as per the config.
* <p>
* This will only set data that is enabled as per the enabled settings in the config file.
* Data present in the {@link UserData} object, but not enabled to be set in the config, will be ignored.
*
* @param plugin The plugin instance
* @return a future returning a boolean when complete; if the sync was successful, the future will return {@code true}.
*/
public final CompletableFuture<Boolean> setData(@NotNull UserData data, @NotNull HuskSync plugin) {
return CompletableFuture.supplyAsync(() -> {
// Prevent synchronising user data from newer versions of Minecraft
if (Version.fromString(data.getMinecraftVersion()).compareTo(plugin.getMinecraftVersion()) > 0) {
plugin.log(Level.SEVERE, "Cannot set data for " + username +
" because the Minecraft version of their user data (" + data.getMinecraftVersion() +
") is newer than the server's Minecraft version (" + plugin.getMinecraftVersion() + ").");
return false;
}
// Prevent synchronising user data from newer versions of the plugin
if (data.getFormatVersion() > UserData.CURRENT_FORMAT_VERSION) {
plugin.log(Level.SEVERE, "Cannot set data for " + username +
" because the format version of their user data (v" + data.getFormatVersion() +
") is newer than the current format version (v" + UserData.CURRENT_FORMAT_VERSION + ").");
return false;
}
// Fire the PreSyncEvent
final PreSyncEvent preSyncEvent = (PreSyncEvent) plugin.getEventCannon().firePreSyncEvent(this, data).join();
final UserData finalData = preSyncEvent.getUserData();
final List<CompletableFuture<Void>> dataSetOperations = new ArrayList<>() {{
if (!isOffline() && !preSyncEvent.isCancelled()) {
final Settings settings = plugin.getSettings();
if (settings.getSynchronizationFeature(Settings.SynchronizationFeature.INVENTORIES)) {
finalData.getInventory().ifPresent(itemData -> add(setInventory(itemData)));
}
if (settings.getSynchronizationFeature(Settings.SynchronizationFeature.ENDER_CHESTS)) {
finalData.getEnderChest().ifPresent(itemData -> add(setEnderChest(itemData)));
}
finalData.getStatus().ifPresent(statusData -> add(setStatus(statusData, settings)));
if (settings.getSynchronizationFeature(Settings.SynchronizationFeature.POTION_EFFECTS)) {
finalData.getPotionEffects().ifPresent(potionEffectData -> add(setPotionEffects(potionEffectData)));
}
if (settings.getSynchronizationFeature(Settings.SynchronizationFeature.ADVANCEMENTS)) {
finalData.getAdvancements().ifPresent(advancementData -> add(setAdvancements(advancementData)));
}
if (settings.getSynchronizationFeature(Settings.SynchronizationFeature.STATISTICS)) {
finalData.getStatistics().ifPresent(statisticData -> add(setStatistics(statisticData)));
}
if (settings.getSynchronizationFeature(Settings.SynchronizationFeature.LOCATION)) {
finalData.getLocation().ifPresent(locationData -> add(setLocation(locationData)));
}
if (settings.getSynchronizationFeature(Settings.SynchronizationFeature.PERSISTENT_DATA_CONTAINER)) {
finalData.getPersistentDataContainer().ifPresent(persistentDataContainerData ->
add(setPersistentDataContainer(persistentDataContainerData)));
}
}
}};
// Apply operations in parallel, join when complete
return CompletableFuture.allOf(dataSetOperations.toArray(new CompletableFuture[0])).thenApply(unused -> true)
.exceptionally(exception -> {
// Handle synchronisation exceptions
plugin.log(Level.SEVERE, "Failed to set data for player " + username + " (" + exception.getMessage() + ")");
exception.printStackTrace();
return false;
}).join();
});
}
/**
* Get the player's current {@link UserData} in an {@link Optional}.
* <p>
* Since v2.1, this method will respect the data synchronisation settings; user data will only be as big as the
* enabled synchronisation values set in the config file
* <p>
* Also note that if the {@code SYNCHRONIZATION_SAVE_DEAD_PLAYER_INVENTORIES} ConfigOption has been set,
* the user's inventory will only be returned if the player is alive.
* <p>
* If the user data could not be returned due to an exception, the optional will return empty
*
* @param plugin The plugin instance
*/
public final CompletableFuture<Optional<UserData>> getUserData(@NotNull HuskSync plugin) {
return CompletableFuture.supplyAsync(() -> {
final UserDataBuilder builder = UserData.builder(getMinecraftVersion());
final List<CompletableFuture<Void>> dataGetOperations = new ArrayList<>() {{
if (!isOffline()) {
final Settings settings = plugin.getSettings();
if (settings.getSynchronizationFeature(Settings.SynchronizationFeature.INVENTORIES)) {
if (isDead() && settings.isSynchroniseDeadPlayersChangingServer()) {
plugin.debug("Player " + username + " is dead, so their inventory will be set to empty.");
add(CompletableFuture.runAsync(() -> builder.setInventory(ItemData.empty())));
} else {
add(getInventory().thenAccept(builder::setInventory));
}
}
if (settings.getSynchronizationFeature(Settings.SynchronizationFeature.ENDER_CHESTS)) {
add(getEnderChest().thenAccept(builder::setEnderChest));
}
add(getStatus().thenAccept(builder::setStatus));
if (settings.getSynchronizationFeature(Settings.SynchronizationFeature.POTION_EFFECTS)) {
add(getPotionEffects().thenAccept(builder::setPotionEffects));
}
if (settings.getSynchronizationFeature(Settings.SynchronizationFeature.ADVANCEMENTS)) {
add(getAdvancements().thenAccept(builder::setAdvancements));
}
if (settings.getSynchronizationFeature(Settings.SynchronizationFeature.STATISTICS)) {
add(getStatistics().thenAccept(builder::setStatistics));
}
if (settings.getSynchronizationFeature(Settings.SynchronizationFeature.LOCATION)) {
add(getLocation().thenAccept(builder::setLocation));
}
if (settings.getSynchronizationFeature(Settings.SynchronizationFeature.PERSISTENT_DATA_CONTAINER)) {
add(getPersistentDataContainer().thenAccept(builder::setPersistentDataContainer));
}
}
}};
// Apply operations in parallel, join when complete
CompletableFuture.allOf(dataGetOperations.toArray(new CompletableFuture[0])).join();
return Optional.of(builder.build());
}).exceptionally(exception -> {
plugin.log(Level.SEVERE, "Failed to get user data from online player " + username + " (" + exception.getMessage() + ")");
exception.printStackTrace();
return Optional.empty();
});
}
/**
* Get if the player is locked
*
* @return the player's locked status
*/
public abstract boolean isLocked();
/**
* Get if the player is a NPC
*
* @return if the player is a NPC with metadata
*/
public abstract boolean isNpc();
}

View File

@@ -28,14 +28,24 @@ public enum RedisKeyType {
DATA_UPDATE(10),
SERVER_SWITCH(10);
public final int timeToLive;
private final int timeToLive;
RedisKeyType(int timeToLive) {
this.timeToLive = timeToLive;
}
@NotNull
public String getKeyPrefix() {
return RedisManager.KEY_NAMESPACE.toLowerCase(Locale.ENGLISH) + ":" + RedisManager.clusterId.toLowerCase(Locale.ENGLISH) + ":" + name().toLowerCase(Locale.ENGLISH);
public String getKeyPrefix(@NotNull String clusterId) {
return String.format(
"%s:%s:%s",
RedisManager.KEY_NAMESPACE.toLowerCase(Locale.ENGLISH),
clusterId.toLowerCase(Locale.ENGLISH),
name().toLowerCase(Locale.ENGLISH)
);
}
public int getTimeToLive() {
return timeToLive;
}
}

View File

@@ -19,21 +19,24 @@
package net.william278.husksync.redis;
import de.themoep.minedown.adventure.MineDown;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.UserData;
import net.william278.husksync.player.User;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.NotNull;
import redis.clients.jedis.*;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.JedisPubSub;
import redis.clients.jedis.exceptions.JedisException;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.Optional;
import java.util.UUID;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
/**
* Manages the connection to the Redis server, handling the caching of user data
@@ -41,216 +44,247 @@ import java.util.concurrent.CompletableFuture;
public class RedisManager extends JedisPubSub {
protected static final String KEY_NAMESPACE = "husksync:";
protected static String clusterId = "";
private final HuskSync plugin;
private final JedisPoolConfig jedisPoolConfig;
private final String redisHost;
private final int redisPort;
private final String redisPassword;
private final boolean redisUseSsl;
private final String clusterId;
private JedisPool jedisPool;
private final Map<UUID, CompletableFuture<Optional<DataSnapshot.Packed>>> pendingRequests;
public RedisManager(@NotNull HuskSync plugin) {
this.plugin = plugin;
clusterId = plugin.getSettings().getClusterId();
// Set redis credentials
this.redisHost = plugin.getSettings().getRedisHost();
this.redisPort = plugin.getSettings().getRedisPort();
this.redisPassword = plugin.getSettings().getRedisPassword();
this.redisUseSsl = plugin.getSettings().isRedisUseSsl();
// Configure the jedis pool
this.jedisPoolConfig = new JedisPoolConfig();
this.jedisPoolConfig.setMaxIdle(0);
this.jedisPoolConfig.setTestOnBorrow(true);
this.jedisPoolConfig.setTestOnReturn(true);
this.clusterId = plugin.getSettings().getClusterId();
this.pendingRequests = new ConcurrentHashMap<>();
}
/**
* Initialize the redis connection pool
*
* @return a future returning void when complete
*/
public boolean initialize() {
if (redisPassword.isBlank()) {
jedisPool = new JedisPool(jedisPoolConfig, redisHost, redisPort, 0, redisUseSsl);
} else {
jedisPool = new JedisPool(jedisPoolConfig, redisHost, redisPort, 0, redisPassword, redisUseSsl);
}
@Blocking
public void initialize() throws IllegalStateException {
final String password = plugin.getSettings().getRedisPassword();
final String host = plugin.getSettings().getRedisHost();
final int port = plugin.getSettings().getRedisPort();
final boolean useSSL = plugin.getSettings().redisUseSsl();
// Create the jedis pool
final JedisPoolConfig config = new JedisPoolConfig();
config.setMaxIdle(0);
config.setTestOnBorrow(true);
config.setTestOnReturn(true);
this.jedisPool = password.isEmpty()
? new JedisPool(config, host, port, 0, useSSL)
: new JedisPool(config, host, port, 0, password, useSSL);
// Ping the server to check the connection
try {
jedisPool.getResource().ping();
} catch (JedisException e) {
return false;
throw new IllegalStateException("Failed to establish connection with the Redis server. "
+ "Please check the supplied credentials in the config file", e);
}
CompletableFuture.runAsync(this::subscribe);
return true;
// Subscribe using a thread (rather than a task)
new Thread(this::subscribe, "husksync:redis_subscriber").start();
}
@Blocking
private void subscribe() {
try (final Jedis subscriber = redisPassword.isBlank() ? new Jedis(redisHost, redisPort, 0, redisUseSsl) :
new Jedis(redisHost, redisPort, DefaultJedisClientConfig.builder()
.password(redisPassword).timeoutMillis(0).ssl(redisUseSsl).build())) {
subscriber.connect();
subscriber.subscribe(this, Arrays.stream(RedisMessageType.values())
.map(RedisMessageType::getMessageChannel)
.toArray(String[]::new));
try (Jedis jedis = jedisPool.getResource()) {
jedis.subscribe(
this,
Arrays.stream(RedisMessageType.values())
.map(type -> type.getMessageChannel(clusterId))
.toArray(String[]::new)
);
}
}
@Override
public void onMessage(@NotNull String channel, @NotNull String message) {
final RedisMessageType messageType = RedisMessageType.getTypeFromChannel(channel).orElse(null);
if (messageType != RedisMessageType.UPDATE_USER_DATA) {
final RedisMessageType messageType = RedisMessageType.getTypeFromChannel(channel, clusterId).orElse(null);
if (messageType == null) {
return;
}
final RedisMessage redisMessage = RedisMessage.fromJson(message);
plugin.getOnlineUser(redisMessage.targetUserUuid).ifPresent(user -> {
final UserData userData = plugin.getDataAdapter().fromBytes(redisMessage.data);
user.setData(userData, plugin).thenAccept(succeeded -> {
if (succeeded) {
switch (plugin.getSettings().getNotificationDisplaySlot()) {
case CHAT -> plugin.getLocales().getLocale("data_update_complete")
.ifPresent(user::sendMessage);
case ACTION_BAR -> plugin.getLocales().getLocale("data_update_complete")
.ifPresent(user::sendActionBar);
case TOAST -> plugin.getLocales().getLocale("data_update_complete")
.ifPresent(locale -> user.sendToast(locale, new MineDown(""),
"minecraft:bell", "TASK"));
}
plugin.getEventCannon().fireSyncCompleteEvent(user);
} else {
plugin.getLocales().getLocale("data_update_failed")
.ifPresent(user::sendMessage);
final RedisMessage redisMessage = RedisMessage.fromJson(plugin, message);
switch (messageType) {
case UPDATE_USER_DATA -> plugin.getOnlineUser(redisMessage.getTargetUuid()).ifPresent(
user -> user.applySnapshot(
DataSnapshot.deserialize(plugin, redisMessage.getPayload()),
DataSnapshot.UpdateCause.UPDATED
)
);
case REQUEST_USER_DATA -> plugin.getOnlineUser(redisMessage.getTargetUuid()).ifPresent(
user -> RedisMessage.create(
UUID.fromString(new String(redisMessage.getPayload(), StandardCharsets.UTF_8)),
user.createSnapshot(DataSnapshot.SaveCause.INVENTORY_COMMAND).asBytes(plugin)
).dispatch(plugin, RedisMessageType.RETURN_USER_DATA)
);
case RETURN_USER_DATA -> {
final CompletableFuture<Optional<DataSnapshot.Packed>> future = pendingRequests.get(
redisMessage.getTargetUuid()
);
if (future != null) {
future.complete(Optional.of(DataSnapshot.deserialize(plugin, redisMessage.getPayload())));
pendingRequests.remove(redisMessage.getTargetUuid());
}
});
});
}
}
}
@Blocking
protected void sendMessage(@NotNull String channel, @NotNull String message) {
try (Jedis jedis = jedisPool.getResource()) {
jedis.publish(channel, message);
}
}
public CompletableFuture<Void> sendUserDataUpdate(@NotNull User user, @NotNull UserData userData) {
return CompletableFuture.runAsync(() -> {
final RedisMessage redisMessage = new RedisMessage(user.uuid, plugin.getDataAdapter().toBytes(userData));
redisMessage.dispatch(this, RedisMessageType.UPDATE_USER_DATA);
public void sendUserDataUpdate(@NotNull User user, @NotNull DataSnapshot.Packed data) {
plugin.runAsync(() -> {
final RedisMessage redisMessage = RedisMessage.create(user.getUuid(), data.asBytes(plugin));
redisMessage.dispatch(plugin, RedisMessageType.UPDATE_USER_DATA);
});
}
public CompletableFuture<Optional<DataSnapshot.Packed>> getUserData(@NotNull UUID requestId, @NotNull User user) {
return plugin.getOnlineUser(user.getUuid())
.map(online -> CompletableFuture.completedFuture(
Optional.of(online.createSnapshot(DataSnapshot.SaveCause.API)))
)
.orElse(this.requestData(requestId, user));
}
private CompletableFuture<Optional<DataSnapshot.Packed>> requestData(@NotNull UUID requestId, @NotNull User user) {
final CompletableFuture<Optional<DataSnapshot.Packed>> future = new CompletableFuture<>();
pendingRequests.put(requestId, future);
plugin.runAsync(() -> {
final RedisMessage redisMessage = RedisMessage.create(
user.getUuid(),
requestId.toString().getBytes(StandardCharsets.UTF_8)
);
redisMessage.dispatch(plugin, RedisMessageType.REQUEST_USER_DATA);
});
return future.orTimeout(
plugin.getSettings().getNetworkLatencyMilliseconds(),
TimeUnit.MILLISECONDS
)
.exceptionally(throwable -> {
pendingRequests.remove(requestId);
return Optional.empty();
});
}
/**
* Set a user's data to the Redis server
*
* @param user the user to set data for
* @param userData the user's data to set
* @return a future returning void when complete
* @param user the user to set data for
* @param data the user's data to set
*/
public CompletableFuture<Void> setUserData(@NotNull User user, @NotNull UserData userData) {
try {
return CompletableFuture.runAsync(() -> {
try (Jedis jedis = jedisPool.getResource()) {
// Set the user's data as a compressed byte array of the json using Snappy
jedis.setex(getKey(RedisKeyType.DATA_UPDATE, user.uuid),
RedisKeyType.DATA_UPDATE.timeToLive,
plugin.getDataAdapter().toBytes(userData));
// Debug logging
plugin.debug("[" + user.username + "] Set " + RedisKeyType.DATA_UPDATE.name()
+ " key to redis at: " +
new SimpleDateFormat("mm:ss.SSS").format(new Date()));
}
});
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
public CompletableFuture<Void> setUserServerSwitch(@NotNull User user) {
return CompletableFuture.runAsync(() -> {
public void setUserData(@NotNull User user, @NotNull DataSnapshot.Packed data) {
plugin.runAsync(() -> {
try (Jedis jedis = jedisPool.getResource()) {
jedis.setex(getKey(RedisKeyType.SERVER_SWITCH, user.uuid),
RedisKeyType.SERVER_SWITCH.timeToLive, new byte[0]);
plugin.debug("[" + user.username + "] Set " + RedisKeyType.SERVER_SWITCH.name()
+ " key to redis at: " +
new SimpleDateFormat("mm:ss.SSS").format(new Date()));
} catch (Exception e) {
e.printStackTrace();
jedis.setex(
getKey(RedisKeyType.DATA_UPDATE, user.getUuid(), clusterId),
RedisKeyType.DATA_UPDATE.getTimeToLive(),
data.asBytes(plugin)
);
plugin.debug(String.format("[%s] Set %s key to redis at: %s", user.getUsername(),
RedisKeyType.DATA_UPDATE.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);
}
});
}
/**
* Set a user's server switch to the Redis server
*
* @param user the user to set the server switch for
* @return a future returning void when complete
*/
public CompletableFuture<Void> setUserServerSwitch(@NotNull User user) {
final CompletableFuture<Void> future = new CompletableFuture<>();
plugin.runAsync(() -> {
try (Jedis jedis = jedisPool.getResource()) {
jedis.setex(
getKey(RedisKeyType.SERVER_SWITCH, user.getUuid(), clusterId),
RedisKeyType.SERVER_SWITCH.getTimeToLive(), new byte[0]
);
future.complete(null);
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())));
} catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred setting a user's server switch", e);
}
});
return future;
}
/**
* Fetch a user's data from the Redis server and consume the key if found
*
* @param user The user to fetch data for
* @return The user's data, if it's present on the database. Otherwise, an empty optional.
*/
public CompletableFuture<Optional<UserData>> getUserData(@NotNull User user) {
return CompletableFuture.supplyAsync(() -> {
try (Jedis jedis = jedisPool.getResource()) {
final byte[] key = getKey(RedisKeyType.DATA_UPDATE, user.uuid);
final byte[] dataByteArray = jedis.get(key);
if (dataByteArray == null) {
plugin.debug("[" + user.username + "] Could not read " +
RedisKeyType.DATA_UPDATE.name() + " key from redis at: " +
new SimpleDateFormat("mm:ss.SSS").format(new Date()));
return Optional.empty();
}
plugin.debug("[" + user.username + "] Successfully read "
+ RedisKeyType.DATA_UPDATE.name() + " key from redis at: " +
new SimpleDateFormat("mm:ss.SSS").format(new Date()));
// Consume the key (delete from redis)
jedis.del(key);
// Use Snappy to decompress the json
return Optional.of(plugin.getDataAdapter().fromBytes(dataByteArray));
} catch (Exception e) {
e.printStackTrace();
public Optional<DataSnapshot.Packed> getUserData(@NotNull User user) {
try (Jedis jedis = jedisPool.getResource()) {
final byte[] key = getKey(RedisKeyType.DATA_UPDATE, user.getUuid(), clusterId);
final byte[] dataByteArray = jedis.get(key);
if (dataByteArray == null) {
plugin.debug("[" + user.getUsername() + "] Could not read " +
RedisKeyType.DATA_UPDATE.name() + " key from redis at: " +
new SimpleDateFormat("mm:ss.SSS").format(new Date()));
return Optional.empty();
}
});
plugin.debug("[" + user.getUsername() + "] Successfully read "
+ RedisKeyType.DATA_UPDATE.name() + " key from redis at: " +
new SimpleDateFormat("mm:ss.SSS").format(new Date()));
// Consume the key (delete from redis)
jedis.del(key);
// Use Snappy to decompress the json
return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray));
} catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred fetching a user's data from redis", e);
return Optional.empty();
}
}
public CompletableFuture<Boolean> getUserServerSwitch(@NotNull User user) {
return CompletableFuture.supplyAsync(() -> {
try (Jedis jedis = jedisPool.getResource()) {
final byte[] key = getKey(RedisKeyType.SERVER_SWITCH, user.uuid);
final byte[] readData = jedis.get(key);
if (readData == null) {
plugin.debug("[" + user.username + "] Could not read " +
RedisKeyType.SERVER_SWITCH.name() + " key from redis at: " +
new SimpleDateFormat("mm:ss.SSS").format(new Date()));
return false;
}
plugin.debug("[" + user.username + "] Successfully read "
+ RedisKeyType.SERVER_SWITCH.name() + " key from redis at: " +
new SimpleDateFormat("mm:ss.SSS").format(new Date()));
// Consume the key (delete from redis)
jedis.del(key);
return true;
} catch (Exception e) {
e.printStackTrace();
public boolean getUserServerSwitch(@NotNull User user) {
try (Jedis jedis = jedisPool.getResource()) {
final byte[] key = getKey(RedisKeyType.SERVER_SWITCH, user.getUuid(), clusterId);
final byte[] readData = jedis.get(key);
if (readData == null) {
plugin.debug("[" + user.getUsername() + "] Could not read " +
RedisKeyType.SERVER_SWITCH.name() + " key from redis at: " +
new SimpleDateFormat("mm:ss.SSS").format(new Date()));
return false;
}
});
plugin.debug("[" + user.getUsername() + "] Successfully read "
+ RedisKeyType.SERVER_SWITCH.name() + " key from redis at: " +
new SimpleDateFormat("mm:ss.SSS").format(new Date()));
// Consume the key (delete from redis)
jedis.del(key);
return true;
} catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred fetching a user's server switch from redis", e);
return false;
}
}
public void close() {
public void terminate() {
if (jedisPool != null) {
if (!jedisPool.isClosed()) {
jedisPool.close();
}
}
this.unsubscribe();
}
private static byte[] getKey(@NotNull RedisKeyType keyType, @NotNull UUID uuid) {
return (keyType.getKeyPrefix() + ":" + uuid).getBytes(StandardCharsets.UTF_8);
private static byte[] getKey(@NotNull RedisKeyType keyType, @NotNull UUID uuid, @NotNull String clusterId) {
return String.format("%s:%s", keyType.getKeyPrefix(clusterId), uuid).getBytes(StandardCharsets.UTF_8);
}
}

View File

@@ -19,34 +19,62 @@
package net.william278.husksync.redis;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import com.google.gson.annotations.SerializedName;
import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.Adaptable;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
public class RedisMessage {
public class RedisMessage implements Adaptable {
public UUID targetUserUuid;
public byte[] data;
@SerializedName("target_uuid")
private UUID targetUuid;
@SerializedName("payload")
private byte[] payload;
public RedisMessage(@NotNull UUID targetUserUuid, byte[] message) {
this.targetUserUuid = targetUserUuid;
this.data = message;
private RedisMessage(@NotNull UUID targetUuid, byte[] message) {
this.setTargetUuid(targetUuid);
this.setPayload(message);
}
@SuppressWarnings("unused")
public RedisMessage() {
}
public void dispatch(@NotNull RedisManager redisManager, @NotNull RedisMessageType type) {
CompletableFuture.runAsync(() -> redisManager.sendMessage(type.getMessageChannel(),
new GsonBuilder().create().toJson(this)));
@NotNull
public static RedisMessage create(@NotNull UUID targetUuid, byte[] message) {
return new RedisMessage(targetUuid, message);
}
@NotNull
public static RedisMessage fromJson(@NotNull String json) throws JsonSyntaxException {
return new GsonBuilder().create().fromJson(json, RedisMessage.class);
public static RedisMessage fromJson(@NotNull HuskSync plugin, @NotNull String json) throws JsonSyntaxException {
return plugin.getGson().fromJson(json, RedisMessage.class);
}
public void dispatch(@NotNull HuskSync plugin, @NotNull RedisMessageType type) {
plugin.runAsync(() -> plugin.getRedisManager().sendMessage(
type.getMessageChannel(plugin.getSettings().getClusterId()),
plugin.getGson().toJson(this)
));
}
@NotNull
public UUID getTargetUuid() {
return targetUuid;
}
public void setTargetUuid(@NotNull UUID targetUuid) {
this.targetUuid = targetUuid;
}
public byte[] getPayload() {
return payload;
}
public void setPayload(byte[] payload) {
this.payload = payload;
}
}

View File

@@ -27,17 +27,24 @@ import java.util.Optional;
public enum RedisMessageType {
UPDATE_USER_DATA;
UPDATE_USER_DATA,
REQUEST_USER_DATA,
RETURN_USER_DATA;
@NotNull
public String getMessageChannel() {
return RedisManager.KEY_NAMESPACE.toLowerCase(Locale.ENGLISH) + ":" + RedisManager.clusterId.toLowerCase(Locale.ENGLISH)
+ ":" + name().toLowerCase(Locale.ENGLISH);
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 messageChannel) {
return Arrays.stream(values()).filter(messageType -> messageType.getMessageChannel()
.equalsIgnoreCase(messageChannel)).findFirst();
public static Optional<RedisMessageType> getTypeFromChannel(@NotNull String channel, @NotNull String clusterId) {
return Arrays.stream(values())
.filter(messageType -> messageType.getMessageChannel(clusterId).equalsIgnoreCase(channel))
.findFirst();
}
}

View File

@@ -17,15 +17,26 @@
* limitations under the License.
*/
package net.william278.husksync;
package net.william278.husksync.user;
import de.themoep.minedown.adventure.MineDown;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.text.Component;
import org.jetbrains.annotations.NotNull;
/**
* Indicates an exception occurred while initializing the HuskSync plugin
*/
public class HuskSyncInitializationException extends IllegalStateException {
public HuskSyncInitializationException(@NotNull String message) {
super(message);
public interface CommandUser {
@NotNull
Audience getAudience();
boolean hasPermission(@NotNull String permission);
default void sendMessage(@NotNull Component component) {
getAudience().sendMessage(component);
}
default void sendMessage(@NotNull MineDown mineDown) {
this.sendMessage(mineDown.toComponent());
}
}

View File

@@ -17,25 +17,28 @@
* limitations under the License.
*/
package net.william278.husksync.data;
package net.william278.husksync.user;
import com.google.gson.annotations.SerializedName;
import net.kyori.adventure.audience.Audience;
import org.jetbrains.annotations.NotNull;
/**
* Stores potion effect data
*/
public class PotionEffectData {
public final class ConsoleUser implements CommandUser {
@SerializedName("serialized_potion_effects")
public String serializedPotionEffects;
@NotNull
private final Audience audience;
public PotionEffectData(@NotNull final String serializedPotionEffects) {
this.serializedPotionEffects = serializedPotionEffects;
public ConsoleUser(@NotNull Audience console) {
this.audience = console;
}
@SuppressWarnings("unused")
protected PotionEffectData() {
@Override
@NotNull
public Audience getAudience() {
return audience;
}
@Override
public boolean hasPermission(@NotNull String permission) {
return true;
}
}

View File

@@ -0,0 +1,188 @@
/*
* 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.user;
import de.themoep.minedown.adventure.MineDown;
import de.themoep.minedown.adventure.MineDownParser;
import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.text.Component;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.data.Identifier;
import net.william278.husksync.data.UserDataHolder;
import org.jetbrains.annotations.NotNull;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
/**
* Represents a logged-in {@link User}
*/
public abstract class OnlineUser extends User implements CommandUser, UserDataHolder {
public OnlineUser(@NotNull UUID uuid, @NotNull String username) {
super(uuid, username);
}
/**
* Indicates if the player has gone offline
*
* @return {@code true} if the player has left the server; {@code false} otherwise
*/
public abstract boolean isOffline();
/**
* Get the player's adventure {@link Audience}
*
* @return the player's {@link Audience}
*/
@NotNull
public abstract Audience getAudience();
/**
* Send a message to this player
*
* @param component the {@link Component} message to send
*/
public void sendMessage(@NotNull Component component) {
getAudience().sendMessage(component);
}
/**
* Dispatch a MineDown-formatted message to this player
*
* @param mineDown the parsed {@link MineDown} to send
*/
public void sendMessage(@NotNull MineDown mineDown) {
sendMessage(mineDown
.disable(MineDownParser.Option.SIMPLE_FORMATTING)
.replace().toComponent());
}
/**
* Dispatch a MineDown-formatted action bar message to this player
*
* @param mineDown the parsed {@link MineDown} to send
*/
public void sendActionBar(@NotNull MineDown mineDown) {
getAudience().sendActionBar(mineDown
.disable(MineDownParser.Option.SIMPLE_FORMATTING)
.replace().toComponent());
}
/**
* Dispatch a toast message to this player
*
* @param title the title of the toast
* @param description the description of the toast
* @param iconMaterial the namespace-keyed material to use as an hasIcon of the toast
* @param backgroundType the background ("ToastType") of the toast
*/
public abstract void sendToast(@NotNull MineDown title, @NotNull MineDown description,
@NotNull String iconMaterial, @NotNull String backgroundType);
/**
* Show a GUI chest menu to the user
*
* @param items the items to fill the menu with
* @param title the title of the menu
* @param editable whether the menu is editable (items can be removed or added)
* @param size the size of the menu
* @param onClose the action to perform when the menu is closed
*/
public abstract void showGui(@NotNull Data.Items.Items items, @NotNull MineDown title, boolean editable, int size,
@NotNull Consumer<Data.Items.Items> onClose);
/**
* Returns if the player has the permission node
*
* @param node The permission node string
* @return {@code true} if the player has permission node; {@code false} otherwise
*/
public abstract boolean hasPermission(@NotNull String node);
/**
* Set a player's status from a {@link DataSnapshot}
*
* @param snapshot The {@link DataSnapshot} to set the player's status from
*/
public void applySnapshot(@NotNull DataSnapshot.Packed snapshot, @NotNull DataSnapshot.UpdateCause cause) {
getPlugin().fireEvent(getPlugin().getPreSyncEvent(this, snapshot), (event) -> {
if (!isOffline()) {
UserDataHolder.super.applySnapshot(
event.getData(), (owner) -> completeSync(true, cause, getPlugin())
);
}
});
}
/**
* Handle a player's synchronization completion
*
* @param succeeded Whether the synchronization succeeded
* @param plugin The plugin instance
*/
public void completeSync(boolean succeeded, @NotNull DataSnapshot.UpdateCause cause, @NotNull HuskSync plugin) {
if (succeeded) {
switch (plugin.getSettings().getNotificationDisplaySlot()) {
case CHAT -> cause.getCompletedLocale(plugin).ifPresent(this::sendMessage);
case ACTION_BAR -> cause.getCompletedLocale(plugin).ifPresent(this::sendActionBar);
case TOAST -> cause.getCompletedLocale(plugin)
.ifPresent(locale -> this.sendToast(
locale, new MineDown(""),
"minecraft:bell",
"TASK"
));
}
plugin.fireEvent(
plugin.getSyncCompleteEvent(this),
(event) -> plugin.getLockedPlayers().remove(getUuid())
);
} else {
cause.getFailedLocale(plugin).ifPresent(this::sendMessage);
}
// Ensure the user is in the database
plugin.getDatabase().ensureUser(this);
}
@NotNull
@Override
public Map<Identifier, Data> getCustomDataStore() {
return getPlugin().getPlayerCustomDataStore(this);
}
/**
* Get if the player is locked
*
* @return the player's locked status
*/
public abstract boolean isLocked();
/**
* Get if the player is a NPC
*
* @return if the player is a NPC with metadata
*/
public abstract boolean isNpc();
}

View File

@@ -17,37 +17,48 @@
* limitations under the License.
*/
package net.william278.husksync.player;
package net.william278.husksync.user;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
/**
* Represents a user who has their data synchronised by HuskSync
* Represents a user who has their data synchronized by HuskSync
*/
public class User {
/**
* The user's unique account ID
*/
public final UUID uuid;
private final UUID uuid;
/**
* The user's username
*/
public final String username;
private final String username;
public User(@NotNull UUID uuid, @NotNull String username) {
this.username = username;
this.uuid = uuid;
}
/**
* Get the user's unique account ID
*/
@NotNull
public UUID getUuid() {
return uuid;
}
/**
* Get the user's username
*/
@NotNull
public String getUsername() {
return username;
}
@Override
public boolean equals(Object object) {
if (object instanceof User other) {
return this.uuid.equals(other.uuid);
return this.getUuid().equals(other.getUuid());
}
return super.equals(object);
}
}

View File

@@ -22,8 +22,8 @@ package net.william278.husksync.util;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.UserDataSnapshot;
import net.william278.husksync.player.User;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.NotNull;
import java.io.*;
@@ -31,38 +31,37 @@ import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.StringJoiner;
import java.util.logging.Level;
/**
* Utility class for dumping {@link UserDataSnapshot}s to a file or as a paste on the web
* Utility class for dumping {@link DataSnapshot}s to a file or as a paste on the web
*/
public class DataDumper {
private static final String LOGS_SITE_ENDPOINT = "https://api.mclo.gs/1/log";
private final HuskSync plugin;
private final UserDataSnapshot dataSnapshot;
private final DataSnapshot.Packed snapshot;
private final User user;
private DataDumper(@NotNull UserDataSnapshot dataSnapshot,
@NotNull User user, @NotNull HuskSync implementor) {
this.dataSnapshot = dataSnapshot;
private DataDumper(@NotNull DataSnapshot.Packed snapshot, @NotNull User user, @NotNull HuskSync implementor) {
this.snapshot = snapshot;
this.user = user;
this.plugin = implementor;
}
/**
* Create a {@link DataDumper} of the given {@link UserDataSnapshot}
* Create a {@link DataDumper} of the given {@link DataSnapshot}
*
* @param dataSnapshot The {@link UserDataSnapshot} to dump
* @param dataSnapshot The {@link DataSnapshot} to dump
* @param user The {@link User} whose data is being dumped
* @param plugin The implementing {@link HuskSync} plugin
* @return A {@link DataDumper} for the given {@link UserDataSnapshot}
* @return A {@link DataDumper} for the given {@link DataSnapshot}
*/
public static DataDumper create(@NotNull UserDataSnapshot dataSnapshot,
public static DataDumper create(@NotNull DataSnapshot.Packed dataSnapshot,
@NotNull User user, @NotNull HuskSync plugin) {
return new DataDumper(dataSnapshot, user, plugin);
}
@@ -75,7 +74,7 @@ public class DataDumper {
@Override
@NotNull
public String toString() {
return plugin.getDataAdapter().toJson(dataSnapshot.userData(), true);
return snapshot.asJson(plugin);
}
@NotNull
@@ -128,7 +127,7 @@ public class DataDumper {
}
/**
* Dump the {@link UserDataSnapshot} to a file and return the file name
* Dump the {@link DataSnapshot} to a file and return the file name
*
* @return the relative path of the file the data was dumped to
*/
@@ -182,11 +181,11 @@ public class DataDumper {
@NotNull
private String getFileName() {
return new StringJoiner("_")
.add(user.username)
.add(new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss").format(dataSnapshot.versionTimestamp()))
.add(dataSnapshot.cause().name().toLowerCase(Locale.ENGLISH))
.add(dataSnapshot.versionUUID().toString().split("-")[0])
+ ".json";
.add(user.getUsername())
.add(snapshot.getTimestamp().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")))
.add(snapshot.getSaveCause().name().toLowerCase(Locale.ENGLISH))
.add(snapshot.getShortId())
+ ".json";
}
}

View File

@@ -19,19 +19,19 @@
package net.william278.husksync.util;
import net.william278.husksync.config.Locales;
import net.william278.husksync.data.UserDataSnapshot;
import net.william278.husksync.player.OnlineUser;
import net.william278.husksync.player.User;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.CommandUser;
import net.william278.husksync.user.User;
import net.william278.paginedown.PaginatedList;
import org.jetbrains.annotations.NotNull;
import java.text.SimpleDateFormat;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Represents a chat-viewable paginated list of {@link UserDataSnapshot}s
* Represents a chat-viewable paginated list of {@link net.william278.husksync.data.DataSnapshot}s
*/
public class DataSnapshotList {
@@ -41,46 +41,52 @@ public class DataSnapshotList {
@NotNull
private final PaginatedList paginatedList;
private DataSnapshotList(@NotNull List<UserDataSnapshot> snapshots, @NotNull User dataOwner,
@NotNull Locales locales) {
private DataSnapshotList(@NotNull List<DataSnapshot.Packed> snapshots, @NotNull User dataOwner,
@NotNull HuskSync plugin) {
final AtomicInteger snapshotNumber = new AtomicInteger(1);
this.paginatedList = PaginatedList.of(snapshots.stream()
.map(snapshot -> locales.getRawLocale("data_list_item",
.map(snapshot -> plugin.getLocales()
.getRawLocale("data_list_item",
getNumberIcon(snapshotNumber.getAndIncrement()),
new SimpleDateFormat("MMM dd yyyy, HH:mm:ss.sss")
.format(snapshot.versionTimestamp()),
snapshot.versionUUID().toString().split("-")[0],
snapshot.versionUUID().toString(),
snapshot.cause().getDisplayName(),
dataOwner.username,
snapshot.pinned() ? "" : " ")
.orElse("" + snapshot.versionUUID())).toList(),
locales.getBaseChatList(6)
.setHeaderFormat(locales.getRawLocale("data_list_title", dataOwner.username,
dataOwner.getUsername(),
snapshot.getId().toString(),
snapshot.getShortId(),
snapshot.isPinned() ? "" : " ",
snapshot.getTimestamp().format(DateTimeFormatter
.ofPattern("dd/MM/yyyy, HH:mm")),
snapshot.getTimestamp().format(DateTimeFormatter
.ofPattern("MMM dd yyyy, HH:mm:ss.SSS")),
snapshot.getSaveCause().getDisplayName(),
String.format("%.2fKiB", snapshot.getFileSize(plugin) / 1024f))
.orElse("" + snapshot.getId())).toList(),
plugin.getLocales().getBaseChatList(6)
.setHeaderFormat(plugin.getLocales()
.getRawLocale("data_list_title", dataOwner.getUsername(),
"%first_item_on_page_index%", "%last_item_on_page_index%", "%total_items%")
.orElse(""))
.setCommand("/husksync:userdata list " + dataOwner.username)
.setCommand("/husksync:userdata list " + dataOwner.getUsername())
.build());
}
/**
* Create a new {@link DataSnapshotList} from a list of {@link UserDataSnapshot}s
* Create a new {@link DataSnapshotList} from a list of {@link DataSnapshot}s
*
* @param snapshots The list of {@link UserDataSnapshot}s to display
* @param user The {@link User} who owns the {@link UserDataSnapshot}s
* @param locales The {@link Locales} instance
* @return A new {@link DataSnapshotList}, to be viewed with {@link #displayPage(OnlineUser, int)}
* @param snapshots The list of {@link DataSnapshot}s to display
* @param user The {@link User} who owns the {@link DataSnapshot}s
* @param plugin The instance of the plugin
* @return A new {@link DataSnapshotList}, to be viewed with {@link #displayPage(CommandUser, int)}
*/
public static DataSnapshotList create(@NotNull List<UserDataSnapshot> snapshots, @NotNull User user,
@NotNull Locales locales) {
return new DataSnapshotList(snapshots, user, locales);
@NotNull
public static DataSnapshotList create(@NotNull List<DataSnapshot.Packed> snapshots, @NotNull User user,
@NotNull HuskSync plugin) {
return new DataSnapshotList(snapshots, user, plugin);
}
/**
* Get an icon for the given snapshot number, via {@link #CIRCLED_NUMBER_ICONS}
* Get an hasIcon for the given snapshot number, via {@link #CIRCLED_NUMBER_ICONS}
*
* @param number the snapshot number
* @return the icon for the given snapshot number
* @return the hasIcon for the given snapshot number
*/
private static String getNumberIcon(int number) {
if (number < 1 || number > 20) {
@@ -90,12 +96,12 @@ public class DataSnapshotList {
}
/**
* Display a page of the list of {@link UserDataSnapshot} to the user
* Display a page of the list of {@link DataSnapshot} to the user
*
* @param onlineUser The online user to display the message to
* @param page The page number to display
*/
public void displayPage(@NotNull OnlineUser onlineUser, int page) {
public void displayPage(@NotNull CommandUser onlineUser, int page) {
onlineUser.sendMessage(paginatedList.getNearestValidPage(page));
}

View File

@@ -0,0 +1,135 @@
/*
* 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.util;
import net.william278.husksync.HuskSync;
import net.william278.husksync.config.Locales;
import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.CommandUser;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.NotNull;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.StringJoiner;
public class DataSnapshotOverview {
private final HuskSync plugin;
private final User dataOwner;
private final DataSnapshot.Unpacked snapshot;
private final long snapshotSize;
private DataSnapshotOverview(@NotNull DataSnapshot.Unpacked snapshot, long snapshotSize,
@NotNull User dataOwner, @NotNull HuskSync plugin) {
this.snapshot = snapshot;
this.snapshotSize = snapshotSize;
this.dataOwner = dataOwner;
this.plugin = plugin;
}
@NotNull
public static DataSnapshotOverview of(@NotNull DataSnapshot.Unpacked snapshot, long snapshotSize,
@NotNull User dataOwner, @NotNull HuskSync plugin) {
return new DataSnapshotOverview(snapshot, snapshotSize, dataOwner, plugin);
}
public void show(@NotNull CommandUser user) {
// Title message, timestamp, owner and cause.
final Locales locales = plugin.getLocales();
locales.getLocale("data_manager_title", snapshot.getShortId(), snapshot.getId().toString(),
dataOwner.getUsername(), dataOwner.getUuid().toString())
.ifPresent(user::sendMessage);
locales.getLocale("data_manager_timestamp",
snapshot.getTimestamp().format(DateTimeFormatter.ofPattern("MMM dd yyyy, HH:mm:ss.SSS")),
snapshot.getTimestamp().toString())
.ifPresent(user::sendMessage);
if (snapshot.isPinned()) {
locales.getLocale("data_manager_pinned")
.ifPresent(user::sendMessage);
}
locales.getLocale("data_manager_cause", snapshot.getSaveCause().getDisplayName())
.ifPresent(user::sendMessage);
// User status data, if present in the snapshot
final Optional<Data.Health> health = snapshot.getHealth();
final Optional<Data.Hunger> food = snapshot.getHunger();
final Optional<Data.Experience> experience = snapshot.getExperience();
final Optional<Data.GameMode> gameMode = snapshot.getGameMode();
if (health.isPresent() && food.isPresent() && experience.isPresent() && gameMode.isPresent()) {
locales.getLocale("data_manager_status",
Integer.toString((int) health.get().getHealth()),
Integer.toString((int) health.get().getMaxHealth()),
Integer.toString(food.get().getFoodLevel()),
Integer.toString(experience.get().getExpLevel()),
gameMode.get().getGameMode().toLowerCase(Locale.ENGLISH))
.ifPresent(user::sendMessage);
}
// Snapshot size
locales.getLocale("data_manager_size", String.format("%.2fKiB", snapshotSize / 1024f))
.ifPresent(user::sendMessage);
// Advancement and statistic data, if both are present in the snapshot
snapshot.getAdvancements()
.flatMap(advancementData -> snapshot.getStatistics()
.flatMap(statisticsData -> locales.getLocale("data_manager_advancements_statistics",
Integer.toString(advancementData.getCompletedExcludingRecipes().size()),
generateAdvancementPreview(advancementData.getCompletedExcludingRecipes(), locales),
String.format("%.2f", (((statisticsData.getGenericStatistics().getOrDefault(
"minecraft:play_one_minute", 0)) / 20d) / 60d) / 60d))))
.ifPresent(user::sendMessage);
if (user.hasPermission("husksync.command.inventory.edit")
&& user.hasPermission("husksync.command.enderchest.edit")) {
locales.getLocale("data_manager_item_buttons", dataOwner.getUsername(), snapshot.getId().toString())
.ifPresent(user::sendMessage);
}
locales.getLocale("data_manager_management_buttons", dataOwner.getUsername(), snapshot.getId().toString())
.ifPresent(user::sendMessage);
if (user.hasPermission("husksync.command.userdata.dump")) {
locales.getLocale("data_manager_system_buttons", dataOwner.getUsername(), snapshot.getId().toString())
.ifPresent(user::sendMessage);
}
}
@NotNull
private String generateAdvancementPreview(@NotNull List<Data.Advancements.Advancement> advancementData, @NotNull Locales locales) {
final StringJoiner joiner = new StringJoiner("\n");
final int PREVIEW_SIZE = 8;
for (int i = 0; i < advancementData.size(); i++) {
joiner.add(advancementData.get(i).getKey());
if (i >= PREVIEW_SIZE) {
break;
}
}
final int remaining = advancementData.size() - PREVIEW_SIZE;
if (remaining > 0) {
joiner.add(locales.getRawLocale("data_manager_advancements_preview_remaining",
Integer.toString(remaining))
.orElse(String.format("+%s…", remaining)));
}
return joiner.toString();
}
}

View File

@@ -17,18 +17,22 @@
* limitations under the License.
*/
package net.william278.husksync.data;
package net.william278.husksync.util;
import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.DataAdapter;
import net.william278.husksync.data.DataSnapshot;
import org.jetbrains.annotations.NotNull;
/**
* Indicates an error occurred during Base-64 serialization and deserialization of data.
* </p>
* For example, an exception deserializing {@link ItemData} item stack or {@link PotionEffectData} potion effect arrays
*/
public class DataSerializationException extends RuntimeException {
protected DataSerializationException(@NotNull String message, @NotNull Throwable cause) {
super(message, cause);
public abstract class LegacyConverter {
protected final HuskSync plugin;
protected LegacyConverter(@NotNull HuskSync plugin) {
this.plugin = plugin;
}
@NotNull
public abstract DataSnapshot.Packed convert(@NotNull byte[] data) throws DataAdapter.AdaptionException;
}

View File

@@ -0,0 +1,144 @@
/*
* 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.util;
import net.william278.husksync.HuskSync;
import org.jetbrains.annotations.NotNull;
import java.util.concurrent.CompletableFuture;
public interface Task extends Runnable {
abstract class Base implements Task {
protected final HuskSync plugin;
protected final Runnable runnable;
protected boolean cancelled = false;
protected Base(@NotNull HuskSync plugin, @NotNull Runnable runnable) {
this.plugin = plugin;
this.runnable = runnable;
}
public void cancel() {
cancelled = true;
}
@NotNull
@Override
public HuskSync getPlugin() {
return plugin;
}
}
abstract class Async extends Base {
protected long delayTicks;
protected Async(@NotNull HuskSync plugin, @NotNull Runnable runnable, long delayTicks) {
super(plugin, runnable);
this.delayTicks = delayTicks;
}
}
abstract class Sync extends Base {
protected long delayTicks;
protected Sync(@NotNull HuskSync plugin, @NotNull Runnable runnable, long delayTicks) {
super(plugin, runnable);
this.delayTicks = delayTicks;
}
}
abstract class Repeating extends Base {
protected final long repeatingTicks;
protected Repeating(@NotNull HuskSync plugin, @NotNull Runnable runnable, long repeatingTicks) {
super(plugin, runnable);
this.repeatingTicks = repeatingTicks;
}
}
@SuppressWarnings("UnusedReturnValue")
interface Supplier {
@NotNull
Task.Sync getSyncTask(@NotNull Runnable runnable, long delayTicks);
@NotNull
Task.Async getAsyncTask(@NotNull Runnable runnable, long delayTicks);
@NotNull
Task.Repeating getRepeatingTask(@NotNull Runnable runnable, long repeatingTicks);
@NotNull
default Task.Sync runSyncDelayed(@NotNull Runnable runnable, long delayTicks) {
final Task.Sync task = getSyncTask(runnable, delayTicks);
task.run();
return task;
}
default Task.Async runAsyncDelayed(@NotNull Runnable runnable, long delayTicks) {
final Task.Async task = getAsyncTask(runnable, delayTicks);
task.run();
return task;
}
@NotNull
default Task.Sync runSync(@NotNull Runnable runnable) {
return runSyncDelayed(runnable, 0);
}
@NotNull
default Task.Async runAsync(@NotNull Runnable runnable) {
final Task.Async task = getAsyncTask(runnable, 0);
task.run();
return task;
}
default <T> CompletableFuture<T> supplyAsync(@NotNull java.util.function.Supplier<T> supplier) {
final CompletableFuture<T> future = new CompletableFuture<>();
runAsync(() -> {
try {
future.complete(supplier.get());
} catch (Throwable throwable) {
future.completeExceptionally(throwable);
}
});
return future;
}
void cancelTasks();
@NotNull
HuskSync getPlugin();
}
@NotNull
HuskSync getPlugin();
}