9
0
mirror of https://github.com/WiIIiam278/HuskSync.git synced 2026-01-03 14:12:17 +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

@@ -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);
}
}
}