9
0
mirror of https://github.com/WiIIiam278/HuskSync.git synced 2025-12-24 09:09:18 +00:00

Compare commits

...

16 Commits

Author SHA1 Message Date
William278
8d047d8892 build: add 1.21.5 properly to buildscript 2025-04-16 18:25:10 +01:00
William278
cb49ab8d73 build: bump deps 2025-04-16 18:12:09 +01:00
William278
436e85dada feat: add support for Paper 1.21.5 2025-04-16 18:03:00 +01:00
William278
223333882d refactor: remove debug message 2025-04-13 22:05:38 +01:00
ilightwas
06d8dda7dd fix: sql syntax in getUnpinnedSnapshotCount (#485)
An AND on a FROM clause
2025-04-11 14:41:33 +01:00
William278
805ffb19c2 build: bump lombok to 1.18.38 2025-04-09 19:07:27 +01:00
William278
cd3e4ef063 fix: sql syntax error with getUnpinnedSnapshotCount 2025-04-09 19:05:55 +01:00
dependabot[bot]
557b738511 deps: bump com.google.guava:guava from 33.4.5-jre to 33.4.6-jre (#473)
Bumps [com.google.guava:guava](https://github.com/google/guava) from 33.4.5-jre to 33.4.6-jre.
- [Release notes](https://github.com/google/guava/releases)
- [Commits](https://github.com/google/guava/commits)

---
updated-dependencies:
- dependency-name: com.google.guava:guava
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-05 23:34:33 +01:00
dependabot[bot]
8ee6b7a199 deps: bump com.zaxxer:HikariCP from 6.2.1 to 6.3.0 (#477)
Bumps [com.zaxxer:HikariCP](https://github.com/brettwooldridge/HikariCP) from 6.2.1 to 6.3.0.
- [Changelog](https://github.com/brettwooldridge/HikariCP/blob/dev/CHANGES)
- [Commits](https://github.com/brettwooldridge/HikariCP/compare/HikariCP-6.2.1...HikariCP-6.3.0)

---
updated-dependencies:
- dependency-name: com.zaxxer:HikariCP
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-05 23:34:26 +01:00
William278
dc880bc37f refactor: optimize rotateSnapshots
Don't pull all snapshots when rotating!
2025-03-30 14:48:37 +01:00
dependabot[bot]
c419587933 deps: bump com.google.guava:guava from 33.4.0-jre to 33.4.5-jre (#470)
Bumps [com.google.guava:guava](https://github.com/google/guava) from 33.4.0-jre to 33.4.5-jre.
- [Release notes](https://github.com/google/guava/releases)
- [Commits](https://github.com/google/guava/commits)

---
updated-dependencies:
- dependency-name: com.google.guava:guava
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-29 16:32:46 +00:00
jhqwqmc
afb4fdd5d5 locales: update zh-cn.yml (#472) 2025-03-29 16:32:02 +00:00
William278
bf8474e02d fix: uncomment petitionServerCheckin method call 2025-03-23 16:18:27 +00:00
William278
937ea9bc8e feat: improve data syncing with checkin petitions
This improves data fetching speed in cases where a user logs out during sync application; when they log back in, the server will petition the server they are checked out on to check them out.

We also now unlock users after saving sync on a server to accommodate this, and track user disconnection status to avoid inconsistencies with what platforms return for `isOnline`
2025-03-23 16:15:00 +00:00
William278
ef7b3c4f32 test: update to junit 5.12.1, use bill of materials 2025-03-23 13:30:04 +00:00
William278
370712c5b2 feat: skip offline users on user data apply 2025-03-20 19:53:31 +00:00
37 changed files with 383 additions and 171 deletions

View File

@@ -54,6 +54,7 @@ jobs:
paper-1.20.1
paper-1.21.1
paper-1.21.4
paper-1.21.5
fabric-1.20.1
fabric-1.21.1
fabric-1.21.4
@@ -61,6 +62,7 @@ jobs:
paper
paper
paper
paper
fabric
fabric
fabric
@@ -68,6 +70,7 @@ jobs:
Paper 1.20.1
Paper 1.21.1
Paper 1.21.4
Paper 1.21.5
Fabric 1.20.1
Fabric 1.21.1
Fabric 1.21.4
@@ -75,6 +78,7 @@ jobs:
target/HuskSync-Bukkit-${{ env.version_name }}+mc.1.20.1.jar
target/HuskSync-Bukkit-${{ env.version_name }}+mc.1.21.1.jar
target/HuskSync-Bukkit-${{ env.version_name }}+mc.1.21.4.jar
target/HuskSync-Bukkit-${{ env.version_name }}+mc.1.21.5.jar
target/HuskSync-Fabric-${{ env.version_name }}+mc.1.20.1.jar
target/HuskSync-Fabric-${{ env.version_name }}+mc.1.21.1.jar
target/HuskSync-Fabric-${{ env.version_name }}+mc.1.21.4.jar

View File

@@ -43,6 +43,7 @@ jobs:
paper-1.20.1
paper-1.21.1
paper-1.21.4
paper-1.21.5
fabric-1.20.1
fabric-1.21.1
fabric-1.21.4
@@ -50,6 +51,7 @@ jobs:
paper
paper
paper
paper
fabric
fabric
fabric
@@ -57,6 +59,7 @@ jobs:
Paper 1.20.1
Paper 1.21.1
Paper 1.21.4
Paper 1.21.5
Fabric 1.20.1
Fabric 1.21.1
Fabric 1.21.4
@@ -64,6 +67,7 @@ jobs:
target/HuskSync-Bukkit-${{ github.event.release.tag_name }}+mc.1.20.1.jar
target/HuskSync-Bukkit-${{ github.event.release.tag_name }}+mc.1.21.1.jar
target/HuskSync-Bukkit-${{ github.event.release.tag_name }}+mc.1.21.4.jar
target/HuskSync-Bukkit-${{ github.event.release.tag_name }}+mc.1.21.5.jar
target/HuskSync-Fabric-${{ github.event.release.tag_name }}+mc.1.20.1.jar
target/HuskSync-Fabric-${{ github.event.release.tag_name }}+mc.1.21.1.jar
target/HuskSync-Fabric-${{ github.event.release.tag_name }}+mc.1.21.4.jar

View File

@@ -46,16 +46,17 @@
## Compatibility
HuskSync supports the following [compatible versions](https://william278.net/docs/husksync/compatibility) of Minecraft. Since v3.7, you must download the correct version of HuskSync for your server:
| Minecraft | Latest HuskSync | Java Version | Platforms | Support Status |
|:---------------:|:---------------:|:------------:|:--------------|:-----------------------------|
| 1.21.4 | _latest_ | 21 | Paper, Fabric | ✅ **Active Release** |
| 1.21.3 | 3.7.1 | 21 | Paper, Fabric | 🗃️ Archived (December 2024) |
| 1.21.1 | _latest_ | 21 | Paper, Fabric | **November 2025** (LTS) |
| 1.20.6 | 3.6.8 | 17 | Paper | 🗃️ Archived (October 2024) |
| 1.20.4 | 3.6.8 | 17 | Paper | 🗃️ Archived (July 2024) |
| 1.20.1 | _latest_ | 17 | Paper, Fabric | ✅ **November 2025** (LTS) |
| 1.17.1 - 1.19.4 | 3.6.8 | 17 | Paper | 🗃️ Archived |
| 1.16.5 | 3.2.1 | 16 | Paper | 🗃️ Archived |
| Minecraft | Latest HuskSync | Java Version | Platforms | Support Status |
|:---------------:|:---------------:|:------------:|:--------------|:------------------------------|
| 1.21.5 | _latest_ | 21 | Paper | ✅ **Active Release** |
| 1.21.4 | _latest_ | 21 | Paper, Fabric | **November 2025** (Non-LTS) |
| 1.21.3 | 3.7.1 | 21 | Paper, Fabric | 🗃️ Archived (December 2024) |
| 1.21.1 | _latest_ | 21 | Paper, Fabric | ✅ **November 2025** (LTS) |
| 1.20.6 | 3.6.8 | 17 | Paper | 🗃️ Archived (October 2024) |
| 1.20.4 | 3.6.8 | 17 | Paper | 🗃️ Archived (July 2024) |
| 1.20.1 | _latest_ | 17 | Paper, Fabric | ✅ **November 2025** (LTS) |
| 1.17.1 - 1.19.4 | 3.6.8 | 17 | Paper | 🗃️ Archived |
| 1.16.5 | 3.2.1 | 16 | Paper | 🗃️ Archived |
HuskSync is primarily developed against the latest release. Old Minecraft versions are allocated a support channel based on popularity, mod support, etc:

View File

@@ -89,9 +89,9 @@ allprojects {
}
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.11.4'
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.11.4'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.11.4'
testImplementation(platform("org.junit:junit-bom:5.12.1"))
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testCompileOnly 'org.jetbrains:annotations:26.0.2'
}

View File

@@ -0,0 +1,3 @@
minecraft_version_numeric=12105
minecraft_api_version=1.21
paper_api_version=1.21.5-R0.1-SNAPSHOT

View File

@@ -8,8 +8,8 @@ plugins {
dependencies {
implementation project(path: ':common')
implementation 'net.william278.uniform:uniform-bukkit:1.3.1'
implementation 'net.william278.uniform:uniform-paper:1.3.1'
implementation 'net.william278.uniform:uniform-bukkit:1.3.3'
implementation 'net.william278.uniform:uniform-paper:1.3.3'
implementation 'net.william278.toilet:toilet-bukkit:1.0.12'
implementation 'net.william278:mpdbdataconverter:1.0.1'
implementation 'net.william278:hsldataconverter:1.0'
@@ -23,17 +23,17 @@ dependencies {
compileOnly "io.papermc.paper:paper-api:${paper_api_version}"
compileOnly 'com.github.retrooper:packetevents-spigot:2.7.0'
compileOnly 'com.github.dmulloy2:ProtocolLib:5.3.0'
compileOnly 'org.projectlombok:lombok:1.18.36'
compileOnly 'commons-io:commons-io:2.18.0'
compileOnly 'org.projectlombok:lombok:1.18.38'
compileOnly 'commons-io:commons-io:2.19.0'
compileOnly 'org.json:json:20250107'
compileOnly 'net.william278:minedown:1.8.2'
compileOnly 'de.exlll:configlib-yaml:4.5.0'
compileOnly 'com.zaxxer:HikariCP:6.2.1'
compileOnly 'com.zaxxer:HikariCP:6.3.0'
compileOnly 'net.william278:DesertWell:2.0.4'
compileOnly 'net.william278:AdvancementAPI:97a9583413'
compileOnly "redis.clients:jedis:$jedis_version"
annotationProcessor 'org.projectlombok:lombok:1.18.36'
annotationProcessor 'org.projectlombok:lombok:1.18.38'
}
processResources {

View File

@@ -99,6 +99,7 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
private final Map<Integer, MapView> mapViews = Maps.newConcurrentMap();
private final List<Migrator> availableMigrators = Lists.newArrayList();
private final Set<UUID> lockedPlayers = Sets.newConcurrentHashSet();
private final Set<UUID> disconnectingPlayers = Sets.newConcurrentHashSet();
private boolean disabling;
private Gson gson;
@@ -349,7 +350,8 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
case "1.20.5", "1.20.6" -> DataFixerUtil.VERSION1_20_5;
case "1.21", "1.21.1" -> DataFixerUtil.VERSION1_21;
case "1.21.2", "1.21.3" -> DataFixerUtil.VERSION1_21_2;
case "1.21.4" -> 4189/*DataFixerUtil.VERSION1_21_4*/;
case "1.21.4" -> 4189;
case "1.21.5" -> DataFixerUtil.VERSION1_21_5;
default -> DataFixerUtil.getCurrentVersion();
};
}

View File

@@ -31,7 +31,11 @@ public interface BukkitUserDataHolder extends UserDataHolder {
@Override
default Optional<? extends Data> getData(@NotNull Identifier id) {
if (!id.isCustom()) {
if (id.isCustom()) {
return Optional.ofNullable(getCustomDataStore().get(id));
}
try {
return switch (id.getKeyValue()) {
case "inventory" -> getInventory();
case "ender_chest" -> getEnderChest();
@@ -48,8 +52,10 @@ public interface BukkitUserDataHolder extends UserDataHolder {
case "persistent_data" -> getPersistentData();
default -> throw new IllegalStateException(String.format("Unexpected data type: %s", id));
};
} catch (Throwable e) {
getPlugin().debug("Failed to get data for key: " + id.asMinimalString(), e);
return Optional.empty();
}
return Optional.ofNullable(getCustomDataStore().get(id));
}
@Override

View File

@@ -40,7 +40,6 @@ import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.jetbrains.annotations.NotNull;
import java.util.Objects;
import java.util.UUID;
@Getter
@@ -124,7 +123,6 @@ public class BukkitLockedEventListener implements LockedHandler, Listener {
private void cancelPlayerEvent(@NotNull UUID uuid, @NotNull Cancellable event) {
if (cancelPlayerEvent(uuid)) {
event.setCancelled(true);
plugin.debug("Cancelled event " + event.getClass().getSimpleName() + " from " + Objects.requireNonNull(plugin.getServer().getPlayer(uuid)).getName());
}
}

View File

@@ -44,6 +44,7 @@ public class PaperEventListener extends BukkitEventListener {
}
@Override
@SuppressWarnings("RedundantMethodOverride")
public void onEnable() {
getPlugin().getServer().getPluginManager().registerEvents(this, getPlugin());
lockedHandler.onEnable();

View File

@@ -57,8 +57,9 @@ public class BukkitUser extends OnlineUser implements BukkitUserDataHolder {
}
@Override
public boolean isOffline() {
return player == null || !player.isOnline();
public boolean hasDisconnected() {
return getPlugin().getDisconnectingPlayers().contains(getUuid())
|| player == null || !player.isOnline();
}
@Override

View File

@@ -3,30 +3,30 @@ plugins {
}
dependencies {
api 'commons-io:commons-io:2.18.0'
api 'org.apache.commons:commons-text:1.13.0'
api 'commons-io:commons-io:2.19.0'
api 'org.apache.commons:commons-text:1.13.1'
api 'net.william278:minedown:1.8.2'
api 'net.william278:mapdataapi:2.0'
api 'org.json:json:20250107'
api 'com.google.code.gson:gson:2.12.1'
api 'com.google.code.gson:gson:2.13.0'
api 'com.fatboyindustrial.gson-javatime-serialisers:gson-javatime-serialisers:1.1.2'
api 'de.exlll:configlib-yaml:4.5.0'
api 'net.william278:paginedown:1.1.2'
api 'net.william278:DesertWell:2.0.4'
api('com.zaxxer:HikariCP:6.2.1') {
api('com.zaxxer:HikariCP:6.3.0') {
exclude module: 'slf4j-api'
}
compileOnlyApi 'net.william278.toilet:toilet-common:1.0.12'
compileOnly 'net.william278.uniform:uniform-common:1.3.1'
compileOnly 'net.william278.uniform:uniform-common:1.3.3'
compileOnly 'com.mojang:brigadier:1.1.8'
compileOnly 'org.projectlombok:lombok:1.18.36'
compileOnly 'org.projectlombok:lombok:1.18.38'
compileOnly 'org.jetbrains:annotations:26.0.2'
compileOnly 'net.kyori:adventure-api:4.19.0'
compileOnly 'net.kyori:adventure-api:4.20.0'
compileOnly 'net.kyori:adventure-platform-api:4.3.4'
compileOnly "net.kyori:adventure-text-serializer-plain:4.19.0"
compileOnly 'com.google.guava:guava:33.4.0-jre'
compileOnly "net.kyori:adventure-text-serializer-plain:4.20.0"
compileOnly 'com.google.guava:guava:33.4.6-jre'
compileOnly 'com.github.plan-player-analytics:Plan:5.5.2272'
compileOnly "redis.clients:jedis:$jedis_version"
compileOnly "com.mysql:mysql-connector-j:$mysql_driver_version"
@@ -37,10 +37,10 @@ dependencies {
testImplementation "redis.clients:jedis:$jedis_version"
testImplementation "org.xerial.snappy:snappy-java:$snappy_version"
testImplementation 'com.google.guava:guava:33.4.0-jre'
testImplementation 'com.google.guava:guava:33.4.6-jre'
testImplementation 'com.github.plan-player-analytics:Plan:5.5.2272'
testCompileOnly 'de.exlll:configlib-yaml:4.5.0'
testCompileOnly 'org.jetbrains:annotations:26.0.2'
annotationProcessor 'org.projectlombok:lombok:1.18.36'
annotationProcessor 'org.projectlombok:lombok:1.18.38'
}

View File

@@ -20,6 +20,7 @@
package net.william278.husksync;
import com.fatboyindustrial.gsonjavatime.Converters;
import com.google.common.collect.Maps;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import net.kyori.adventure.audience.Audience;
@@ -140,7 +141,7 @@ public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider
if (getPlayerCustomDataStore().containsKey(user.getUuid())) {
return getPlayerCustomDataStore().get(user.getUuid());
}
final Map<Identifier, Data> data = new HashMap<>();
final Map<Identifier, Data> data = Maps.newHashMap();
getPlayerCustomDataStore().put(user.getUuid(), data);
return data;
}
@@ -315,6 +316,12 @@ public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider
@NotNull
Set<UUID> getLockedPlayers();
/**
* Get the set of UUIDs of players who are currently marked as disconnecting or disconnected
*/
@NotNull
Set<UUID> getDisconnectingPlayers();
default boolean isLocked(@NotNull UUID uuid) {
return getLockedPlayers().contains(uuid);
}

View File

@@ -22,6 +22,7 @@ package net.william278.husksync.data;
import lombok.*;
import net.kyori.adventure.key.InvalidKeyException;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.key.KeyPattern;
import org.intellij.lang.annotations.Subst;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
@@ -39,6 +40,9 @@ import java.util.stream.Stream;
@Getter
public class Identifier {
// Namespace for built-in identifiers
private static final @KeyPattern String DEFAULT_NAMESPACE = "husksync";
// Built-in identifiers
public static final Identifier PERSISTENT_DATA = huskSync("persistent_data", true);
public static final Identifier INVENTORY = huskSync("inventory", true);
@@ -93,8 +97,8 @@ public class Identifier {
*/
@NotNull
public static Identifier from(@NotNull Key key, @NotNull Set<Dependency> dependencies) {
if (key.namespace().equals("husksync")) {
throw new IllegalArgumentException("You cannot register a key with \"husksync\" as the namespace!");
if (key.namespace().equals(DEFAULT_NAMESPACE)) {
throw new IllegalArgumentException("Cannot register with %s as key namespace!".formatted(key.namespace()));
}
return new Identifier(key, true, dependencies);
}
@@ -143,7 +147,7 @@ public class Identifier {
@NotNull
private static Identifier huskSync(@Subst("null") @NotNull String name,
boolean configDefault) throws InvalidKeyException {
return new Identifier(Key.key("husksync", name), configDefault, Collections.emptySet());
return new Identifier(Key.key(DEFAULT_NAMESPACE, name), configDefault, Collections.emptySet());
}
// Return an identifier with a HuskSync namespace
@@ -151,7 +155,7 @@ public class Identifier {
private static Identifier huskSync(@Subst("null") @NotNull String name,
@SuppressWarnings("SameParameterValue") boolean configDefault,
@NotNull Dependency... dependents) throws InvalidKeyException {
return new Identifier(Key.key("husksync", name), configDefault, Set.of(dependents));
return new Identifier(Key.key(DEFAULT_NAMESPACE, name), configDefault, Set.of(dependents));
}
/**
@@ -209,13 +213,30 @@ public class Identifier {
* @return {@code false} if {@link #getKeyNamespace()} returns "husksync"; {@code true} otherwise
*/
public boolean isCustom() {
return !getKeyNamespace().equals("husksync");
return !getKeyNamespace().equals(DEFAULT_NAMESPACE);
}
/**
* Get the minimal string representation of this key.
* <p>
* If the namespace of the key is {@link #DEFAULT_NAMESPACE}, only the key value will be returned.
*
* @return the minimal string key representation
* @since 3.8
*/
@NotNull
public String asMinimalString() {
if (getKey().namespace().equals(DEFAULT_NAMESPACE)) {
return getKey().value();
}
return getKey().asString();
}
/**
* Returns the identifier as a string (the key)
*
* @return the identifier as a string
* @since 3.0
*/
@NotNull
@Override
@@ -224,19 +245,29 @@ public class Identifier {
}
/**
* Returns {@code true} if the given object is an identifier with the same key as this identifier
* Return whether this Identifier is equal to another Identifier
*
* @param obj the object to compare
* @return {@code true} if the given object is an identifier with the same key as this identifier
* @param obj another object
* @return {@code true} if this identifier matches the identifier of {@code obj}
* @since 3.8
*/
@Override
public boolean equals(@Nullable Object obj) {
return obj instanceof Identifier other ? toString().equals(other.toString()) : super.equals(obj);
if (obj instanceof Identifier other) {
return asMinimalString().equals(other.asMinimalString());
}
return false;
}
/**
* Get the hash code of the Identifier (equivalent to {@link #asMinimalString()}->{@code #hashCode()}
*
* @return the hash code
* @since 3.8
*/
@Override
public int hashCode() {
return key.toString().hashCode();
return asMinimalString().hashCode();
}
// Get the config entry for the identifier

View File

@@ -75,6 +75,15 @@ public interface UserDataHolder extends DataHolder {
return DataSnapshot.builder(getPlugin()).data(this.getData()).saveCause(saveCause).buildAndPack();
}
/**
* Returns whether data can be applied to the holder at this time
*
* @return {@code true} if data can be applied, otherwise false
*/
default boolean cannotApplySnapshot() {
return false;
}
/**
* Deserialize and apply a data snapshot to this data owner
* <p>
@@ -90,9 +99,12 @@ public interface UserDataHolder extends DataHolder {
* @since 3.0
*/
default void applySnapshot(@NotNull DataSnapshot.Packed snapshot, @NotNull ThrowingConsumer<Boolean> runAfter) {
final HuskSync plugin = getPlugin();
if (cannotApplySnapshot()) {
return;
}
// Unpack the snapshot
final HuskSync plugin = getPlugin();
final DataSnapshot.Unpacked unpacked;
try {
unpacked = snapshot.unpack(plugin);
@@ -104,6 +116,10 @@ public interface UserDataHolder extends DataHolder {
// Synchronously attempt to apply the snapshot
plugin.runSync(() -> {
if (cannotApplySnapshot()) {
return;
}
try {
for (Map.Entry<Identifier, Data> entry : unpacked.getData().entrySet()) {
final Identifier identifier = entry.getKey();

View File

@@ -24,6 +24,7 @@ import net.william278.husksync.HuskSync;
import net.william278.husksync.config.Settings;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.User;
import org.intellij.lang.annotations.Language;
import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -68,7 +69,7 @@ public abstract class Database {
* @return the formatted statement, with table placeholders replaced with the correct names
*/
@NotNull
protected final String formatStatementTables(@NotNull String sql) {
protected final String formatStatementTables(@NotNull @Language("SQL") String sql) {
final Settings.DatabaseSettings settings = plugin.getSettings().getDatabase();
return sql.replaceAll("%users_table%", settings.getTableName(TableName.USERS))
.replaceAll("%user_data_table%", settings.getTableName(TableName.USER_DATA))
@@ -138,6 +139,15 @@ public abstract class Database {
@NotNull
public abstract List<DataSnapshot.Packed> getAllSnapshots(@NotNull User user);
/**
* Get the number of unpinned {@link DataSnapshot}s a user has
*
* @param user the user to count snapshots for
* @return the number of snapshots this user has saved
*/
@Blocking
public abstract int getUnpinnedSnapshotCount(@NotNull User user);
/**
* Gets a specific {@link DataSnapshot} entry for a user from the database, by its UUID.
*
@@ -264,7 +274,7 @@ public abstract class Database {
*
* @param serverName Name of the server the map originates from
* @param mapId Original map ID
* @return Map.Entry (key: map data, value: is from current world)
* @return Map.Entry (key: map data, value: is from current world)
*/
@Blocking
public abstract @Nullable Map.Entry<byte[], Boolean> getMapData(@NotNull String serverName, int mapId);
@@ -274,7 +284,7 @@ public abstract class Database {
*
* @param serverName Name of the server the map originates from
* @param mapId Original map ID
* @return Map.Entry (key: server name, value: map ID)
* @return Map.Entry (key: server name, value: map ID)
*/
@Blocking
public abstract @Nullable Map.Entry<String, Integer> getMapBinding(@NotNull String serverName, int mapId);
@@ -296,7 +306,7 @@ public abstract class Database {
* @param fromServerName Name of the server the map originates from
* @param fromMapId Original map ID
* @param toServerName Name of the new server
* @return New map ID or -1 if not found
* @return New map ID or -1 if not found
*/
@Blocking
public abstract int getBoundMapId(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName);

View File

@@ -234,6 +234,17 @@ public class MongoDbDatabase extends Database {
}
}
@Override
public int getUnpinnedSnapshotCount(@NotNull User user) {
try {
Document filter = new Document("player_uuid", user.getUuid()).append("pinned", false);
return (int) mongoCollectionHelper.getCollection(userDataTable).countDocuments(filter);
} catch (MongoException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user's current snapshot count", e);
}
return 0;
}
@Blocking
@Override
public Optional<DataSnapshot.Packed> getSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
@@ -259,17 +270,14 @@ public class MongoDbDatabase extends Database {
@Override
protected void rotateSnapshots(@NotNull User user) {
try {
final List<DataSnapshot.Packed> unpinnedUserData = getAllSnapshots(user).stream()
.filter(dataSnapshot -> !dataSnapshot.isPinned()).toList();
final int unpinnedSnapshots = getUnpinnedSnapshotCount(user);
final int maxSnapshots = plugin.getSettings().getSynchronization().getMaxUserDataSnapshots();
if (unpinnedUserData.size() > maxSnapshots) {
if (unpinnedSnapshots > maxSnapshots) {
Document filter = new Document("player_uuid", user.getUuid()).append("pinned", false);
Document sort = new Document("timestamp", 1); // 1 = Ascending
FindIterable<Document> iterable = mongoCollectionHelper.getCollection(userDataTable)
.find(filter)
.sort(sort)
.limit(unpinnedUserData.size() - maxSnapshots);
.find(filter).sort(sort)
.limit(unpinnedSnapshots - maxSnapshots);
for (Document doc : iterable) {
mongoCollectionHelper.deleteDocument(userDataTable, doc);

View File

@@ -299,11 +299,30 @@ public class MySqlDatabase extends Database {
return retrievedData;
}
} catch (SQLException | DataAdapter.AdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
plugin.log(Level.SEVERE, "Failed to fetch a user's list of snapshots from the database", e);
}
return retrievedData;
}
@Override
public int getUnpinnedSnapshotCount(@NotNull User user) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT COUNT(`version_uuid`)
FROM `%user_data_table%`
WHERE `player_uuid`=? AND `pinned`=false;"""))) {
statement.setString(1, user.getUuid().toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
return resultSet.getInt(1);
}
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user's current snapshot count", e);
}
return 0;
}
@Blocking
@Override
public Optional<DataSnapshot.Packed> getSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
@@ -336,10 +355,9 @@ public class MySqlDatabase extends Database {
@Blocking
@Override
protected void rotateSnapshots(@NotNull User user) {
final List<DataSnapshot.Packed> unpinnedUserData = getAllSnapshots(user).stream()
.filter(dataSnapshot -> !dataSnapshot.isPinned()).toList();
final int unpinnedSnapshots = getUnpinnedSnapshotCount(user);
final int maxSnapshots = plugin.getSettings().getSynchronization().getMaxUserDataSnapshots();
if (unpinnedUserData.size() > maxSnapshots) {
if (unpinnedSnapshots > maxSnapshots) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
DELETE FROM `%user_data_table%`
@@ -347,7 +365,7 @@ public class MySqlDatabase extends Database {
AND `pinned` IS FALSE
ORDER BY `timestamp` ASC
LIMIT %entry_count%;""".replace("%entry_count%",
Integer.toString(unpinnedUserData.size() - maxSnapshots))))) {
Integer.toString(unpinnedSnapshots - maxSnapshots))))) {
statement.setString(1, user.getUuid().toString());
statement.executeUpdate();
}

View File

@@ -288,11 +288,30 @@ public class PostgresDatabase extends Database {
return retrievedData;
}
} catch (SQLException | DataAdapter.AdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
plugin.log(Level.SEVERE, "Failed to fetch a user's list of snapshots from the database", e);
}
return retrievedData;
}
@Override
public int getUnpinnedSnapshotCount(@NotNull User user) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT COUNT(`version_uuid`)
FROM `%user_data_table%`
WHERE `player_uuid`=? AND `pinned`=false;"""))) {
statement.setString(1, user.getUuid().toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
return resultSet.getInt(1);
}
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user's current snapshot count", e);
}
return 0;
}
@Blocking
@Override
public Optional<DataSnapshot.Packed> getSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
@@ -323,10 +342,9 @@ public class PostgresDatabase extends Database {
@Blocking
@Override
protected void rotateSnapshots(@NotNull User user) {
final List<DataSnapshot.Packed> unpinnedUserData = getAllSnapshots(user).stream()
.filter(dataSnapshot -> !dataSnapshot.isPinned()).toList();
final int unpinnedSnapshots = getUnpinnedSnapshotCount(user);
final int maxSnapshots = plugin.getSettings().getSynchronization().getMaxUserDataSnapshots();
if (unpinnedUserData.size() > maxSnapshots) {
if (unpinnedSnapshots > maxSnapshots) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
WITH cte AS (
@@ -339,7 +357,7 @@ public class PostgresDatabase extends Database {
)
DELETE FROM %user_data_table%
WHERE version_uuid IN (SELECT version_uuid FROM cte);""".replace("%entry_count%",
Integer.toString(unpinnedUserData.size() - maxSnapshots))))) {
Integer.toString(unpinnedSnapshots - maxSnapshots))))) {
statement.setObject(1, user.getUuid());
statement.executeUpdate();
}

View File

@@ -25,9 +25,7 @@ import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.OnlineUser;
import org.jetbrains.annotations.NotNull;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.*;
import static net.william278.husksync.config.Settings.SynchronizationSettings.SaveOnDeathSettings;
@@ -49,6 +47,7 @@ public abstract class EventListener {
* @param user The {@link OnlineUser} to handle
*/
protected final void handlePlayerJoin(@NotNull OnlineUser user) {
plugin.getDisconnectingPlayers().remove(user.getUuid());
if (user.isNpc()) {
return;
}
@@ -62,11 +61,17 @@ public abstract class EventListener {
* @param user The {@link OnlineUser} to handle
*/
protected final void handlePlayerQuit(@NotNull OnlineUser user) {
if (user.isNpc() || plugin.isDisabling() || plugin.isLocked(user.getUuid())) {
// Check the user is a user, the plugin isn't disabling, then mark as disconnecting
if (user.isNpc() || plugin.isDisabling()) {
return;
}
plugin.lockPlayer(user.getUuid());
plugin.getDataSyncer().syncSaveUserData(user);
plugin.getDisconnectingPlayers().add(user.getUuid());
// Lock, then save their data if the user is unlocked
if (!plugin.isLocked(user.getUuid())) {
plugin.lockPlayer(user.getUuid());
plugin.getDataSyncer().syncSaveUserData(user);
}
}
/**
@@ -79,7 +84,7 @@ public abstract class EventListener {
return;
}
usersInWorld.stream()
.filter(user -> !plugin.isLocked(user.getUuid()) && !user.isNpc())
.filter(user -> !user.isNpc() && !user.hasDisconnected() && !plugin.isLocked(user.getUuid()))
.forEach(user -> plugin.getDataSyncer().saveCurrentUserData(
user, DataSnapshot.SaveCause.WORLD_SAVE
));

View File

@@ -158,7 +158,7 @@ public class RedisManager extends JedisPubSub {
final RedisMessage redisMessage = RedisMessage.fromJson(plugin, message);
switch (messageType) {
case UPDATE_USER_DATA -> plugin.getOnlineUser(redisMessage.getTargetUuid()).ifPresent(
case UPDATE_USER_DATA -> redisMessage.getTargetUser(plugin).ifPresent(
user -> {
plugin.lockPlayer(user.getUuid());
try {
@@ -170,16 +170,30 @@ public class RedisManager extends JedisPubSub {
}
}
);
case REQUEST_USER_DATA -> plugin.getOnlineUser(redisMessage.getTargetUuid()).ifPresent(
case REQUEST_USER_DATA -> redisMessage.getTargetUser(plugin).ifPresent(
user -> RedisMessage.create(
UUID.fromString(new String(redisMessage.getPayload(), StandardCharsets.UTF_8)),
user.createSnapshot(DataSnapshot.SaveCause.INVENTORY_COMMAND).asBytes(plugin)
).dispatch(plugin, RedisMessage.Type.RETURN_USER_DATA)
);
case CHECK_IN_PETITION -> {
if (!redisMessage.isTargetServer(plugin)) {
return;
}
final String payload = new String(redisMessage.getPayload(), StandardCharsets.UTF_8);
final User user = new User(UUID.fromString(payload.split("/")[0]), payload.split("/")[1]);
boolean online = plugin.getDisconnectingPlayers().contains(user.getUuid())
|| plugin.getOnlineUser(user.getUuid()).isEmpty();
if (!online && !plugin.isLocked(user.getUuid())) {
plugin.debug("[%s] Received check-in petition for online/unlocked user, ignoring".formatted(user.getName()));
return;
}
plugin.getRedisManager().setUserCheckedOut(user, false);
plugin.debug("[%s] Received petition for offline user, checking them in".formatted(user.getName()));
}
case RETURN_USER_DATA -> {
final CompletableFuture<Optional<DataSnapshot.Packed>> future = pendingRequests.get(
redisMessage.getTargetUuid()
);
final UUID target = redisMessage.getTargetUuid().orElse(null);
final CompletableFuture<Optional<DataSnapshot.Packed>> future = pendingRequests.get(target);
if (future != null) {
try {
final DataSnapshot.Packed data = DataSnapshot.deserialize(plugin, redisMessage.getPayload());
@@ -188,7 +202,7 @@ public class RedisManager extends JedisPubSub {
plugin.log(Level.SEVERE, "An exception occurred returning user data from Redis", e);
future.complete(Optional.empty());
}
pendingRequests.remove(redisMessage.getTargetUuid());
pendingRequests.remove(target);
}
}
}
@@ -211,11 +225,17 @@ public class RedisManager extends JedisPubSub {
}
}
@Blocking
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, RedisMessage.Type.UPDATE_USER_DATA);
});
final RedisMessage redisMessage = RedisMessage.create(user.getUuid(), data.asBytes(plugin));
redisMessage.dispatch(plugin, RedisMessage.Type.UPDATE_USER_DATA);
}
@Blocking
public void petitionServerCheckin(@NotNull String server, @NotNull User user) {
final RedisMessage redisMessage = RedisMessage.create(
server, "%s/%s".formatted(user.getUuid(), user.getName()).getBytes(StandardCharsets.UTF_8));
redisMessage.dispatch(plugin, RedisMessage.Type.CHECK_IN_PETITION);
}
public CompletableFuture<Optional<DataSnapshot.Packed>> getOnlineUserData(@NotNull UUID requestId, @NotNull User user,
@@ -421,7 +441,7 @@ public class RedisManager extends JedisPubSub {
final long startTime = System.currentTimeMillis();
try (Jedis jedis = jedisPool.getResource()) {
jedis.ping();
return startTime - System.currentTimeMillis();
return System.currentTimeMillis() - startTime;
}
}

View File

@@ -25,25 +25,38 @@ import lombok.Getter;
import lombok.Setter;
import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.Adaptable;
import net.william278.husksync.user.OnlineUser;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
@Setter
public class RedisMessage implements Adaptable {
private @Nullable String targetServer;
@SerializedName("target_uuid")
private UUID targetUuid;
private @Nullable UUID targetUuid;
@Getter
@Setter
@SerializedName("payload")
private byte[] payload;
private RedisMessage(byte[] payload) {
setPayload(payload);
}
private RedisMessage(@NotNull UUID targetUuid, byte[] message) {
this(message);
this.setTargetUuid(targetUuid);
this.setPayload(message);
}
private RedisMessage(@NotNull String targetServer, byte[] message) {
this(message);
this.setTargetServer(targetServer);
}
@SuppressWarnings("unused")
@@ -55,6 +68,11 @@ public class RedisMessage implements Adaptable {
return new RedisMessage(targetUuid, message);
}
@NotNull
public static RedisMessage create(@NotNull String targetServer, byte[] message) {
return new RedisMessage(targetServer, message);
}
@NotNull
public static RedisMessage fromJson(@NotNull HuskSync plugin, @NotNull String json) throws JsonSyntaxException {
return plugin.getGson().fromJson(json, RedisMessage.class);
@@ -67,20 +85,23 @@ public class RedisMessage implements Adaptable {
));
}
@NotNull
public UUID getTargetUuid() {
return targetUuid;
public Optional<UUID> getTargetUuid() {
return Optional.ofNullable(targetUuid);
}
public void setTargetUuid(@NotNull UUID targetUuid) {
this.targetUuid = targetUuid;
public Optional<OnlineUser> getTargetUser(@NotNull HuskSync plugin) {
return getTargetUuid().flatMap(plugin::getOnlineUser);
}
public boolean isTargetServer(@NotNull HuskSync plugin) {
return targetServer != null && targetServer.equals(plugin.getServerName());
}
public enum Type {
UPDATE_USER_DATA,
REQUEST_USER_DATA,
RETURN_USER_DATA;
RETURN_USER_DATA,
CHECK_IN_PETITION;
@NotNull
public String getMessageChannel(@NotNull String clusterId) {

View File

@@ -185,7 +185,7 @@ public abstract class DataSyncer {
final AtomicReference<Task.Repeating> task = new AtomicReference<>();
final AtomicBoolean processing = new AtomicBoolean(false);
final Runnable runnable = () -> {
if (user.isOffline()) {
if (user.cannotApplySnapshot()) {
task.get().cancel();
return;
}

View File

@@ -62,7 +62,10 @@ public class DelayDataSyncer extends DataSyncer {
getRedis().setUserServerSwitch(onlineUser);
saveData(
onlineUser, onlineUser.createSnapshot(DataSnapshot.SaveCause.DISCONNECT),
(user, data) -> getRedis().setUserData(user, data)
(user, data) -> {
getRedis().setUserData(user, data);
plugin.unlockPlayer(user.getUuid());
}
);
});
}

View File

@@ -24,6 +24,8 @@ import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.OnlineUser;
import org.jetbrains.annotations.NotNull;
import java.util.Optional;
public class LockstepDataSyncer extends DataSyncer {
public LockstepDataSyncer(@NotNull HuskSync plugin) {
@@ -44,9 +46,19 @@ public class LockstepDataSyncer extends DataSyncer {
@Override
public void syncApplyUserData(@NotNull OnlineUser user) {
this.listenForRedisData(user, () -> {
if (getRedis().getUserCheckedOut(user).isPresent()) {
if (user.cannotApplySnapshot()) {
plugin.debug("Not checking data state for user who has gone offline: %s".formatted(user.getName()));
return false;
}
// If they are checked out, ask the server to check them back in and return false
final Optional<String> server = getRedis().getUserCheckedOut(user);
if (server.isPresent() && !server.get().equals(plugin.getServerName())) {
getRedis().petitionServerCheckin(server.get(), user);
return false;
}
// If they are checked in - or checked out on *this* server - we can apply their latest data
getRedis().setUserCheckedOut(user, true);
getRedis().getUserData(user).ifPresentOrElse(
data -> user.applySnapshot(data, DataSnapshot.UpdateCause.SYNCHRONIZED),
@@ -63,6 +75,7 @@ public class LockstepDataSyncer extends DataSyncer {
(user, data) -> {
getRedis().setUserData(user, data);
getRedis().setUserCheckedOut(user, false);
plugin.unlockPlayer(user.getUuid());
}
));
}

View File

@@ -43,11 +43,27 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo
}
/**
* Indicates if the player has gone offline
* Indicates if the player is offline
*
* @return {@code true} if the player has left the server; {@code false} otherwise
* @deprecated use {@code hasDisconnected} instead
*/
public abstract boolean isOffline();
@Deprecated(since = "3.8")
public boolean isOffline() {
return hasDisconnected();
}
public abstract boolean hasDisconnected();
// Users cannot have snapshots applied if they have disconnected!
@Override
public boolean cannotApplySnapshot() {
if (hasDisconnected()) {
getPlugin().debug("[%s] Cannot apply snapshot as user is offline!".formatted(getName()));
return true;
}
return false;
}
@NotNull
@Override
@@ -117,7 +133,7 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo
/**
* Set a player's status from a {@link DataSnapshot}
* Apply a {@link DataSnapshot} to a player, updating their data
*
* @param snapshot The {@link DataSnapshot} to set the player's status from
* @param cause The {@link DataSnapshot.UpdateCause} of the snapshot
@@ -125,14 +141,12 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo
*/
public void applySnapshot(@NotNull DataSnapshot.Packed snapshot, @NotNull DataSnapshot.UpdateCause cause) {
getPlugin().fireEvent(getPlugin().getPreSyncEvent(this, snapshot), (event) -> {
if (!isOffline()) {
getPlugin().debug(String.format("Applying snapshot (%s) to %s (cause: %s)",
snapshot.getShortId(), getName(), cause.getDisplayName()
));
UserDataHolder.super.applySnapshot(
event.getData(), (succeeded) -> completeSync(succeeded, cause, getPlugin())
);
}
getPlugin().debug(String.format("Attempting to apply snapshot (%s) to %s (cause: %s)",
snapshot.getShortId(), getName(), cause.getDisplayName()
));
UserDataHolder.super.applySnapshot(
event.getData(), (succeeded) -> completeSync(succeeded, cause, getPlugin())
);
});
}

View File

@@ -23,7 +23,7 @@ locales:
data_list_title: '[%1%的玩家数据备份:](#00fb9a) [(%2%-%3% of](#00fb9a) [%4%](#00fb9a bold)[)](#00fb9a)\n'
data_list_item: '[%1%](gray show_text=&7玩家数据备份 %2%&8⚡ %4% run_command=/userdata view %2% %3%) [%5%](#d8ff2b show_text=&7已置顶:\n&8已置顶的备份不会按照备份时间自动排序 run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962 show_text=&7备份时间:&7\n&8数据保存时间\n&8%7% run_command=/userdata view %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7保存原因:\n&8导致数据保存的原因 run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fa show_text=&7备份大小:&7\n&8预计备份文件大小(以KiB为单位) run_command=/userdata view %2% %3%)'
data_list_item_invalid: '[%1%](dark_gray show_text=&7%2%的用户数据快照\n&8⚡ %4% suggest_command=/userdata delete %2% %3%) [%5%](dark_gray show_text=&7置顶:\n&8已置顶的快照不会自动排序. suggest_command=/userdata delete %2% %3%) [%6% ⚑ %8% ⏏ %9%](gray strikethrough show_text=&#ff3300&无效的快照数据\n&#ff7e5e&点击删除\n\n&7⚠ %10% suggest_command=/userdata delete %2% %3%)'
data_saved: '[Successfully saved a snapshot of %1%''s current user data.](#00fb9a)'
data_saved: '[已成功保存 %1% 的当前用户数据快照.](#00fb9a)'
data_deleted: '[❌ 成功删除玩家](#00fb9a) [%3%](#00fb9a show_text=&7玩家 UUID:\n&7%4%) [的数据备份](#00fb9a) [%1%.](#00fb9a show_text=&7备份版本UUID:\n&7%2%)'
data_restored: '[⏪ 成功恢复玩家](#00fb9a) [%1%](#00fb9a show_text=&7玩家 UUID:\n&7%2%)[的数据备份](#00fb9a) [%3%.](#00fb9a show_text=&7备份版本UUID:\n&7%4%)'
data_pinned: '[※ 成功置顶玩家](#00fb9a) [%3%](#00fb9a show_text=&7玩家 UUID:\n&8%4%) [的数据备份](#00fb9a) [%1%.](#00fb9a show_text=&7备份版本UUID:\n&8%2%)'
@@ -41,8 +41,8 @@ locales:
save_cause_world_save: '保存世界'
save_cause_death: '死亡'
save_cause_server_shutdown: '服务器关闭'
save_cause_save_command: 'save command'
save_cause_dump_command: 'dump command'
save_cause_save_command: '保存命令'
save_cause_dump_command: '转储命令'
save_cause_inventory_command: '背包命令'
save_cause_enderchest_command: '末影箱命令'
save_cause_backup_restore: '备份还原'
@@ -54,9 +54,9 @@ locales:
update_available: '[HuskSync](#ff7e5e bold) [| 检测到HuskSync有新版本可以更新了:v%1%(当前版本:v%2%).](#ff7e5e)'
reload_complete: '[HuskSync](#00fb9a bold) [| 重新加载配置和消息文件完成.](#00fb9a)\n[⚠ 确保在所有服务器上更新配置文件!](#00fb9a)\n[需要重新启动才能使配置更改生效.](#00fb9a italic)'
system_status_header: '[HuskSync](#00fb9a bold) [| 系统状态报告:](#00fb9a)'
system_dump_confirm: '[HuskSync](#00fb9a bold) [| Prepare a system dump? This will include:](#00fb9a)\n[• Your latest server logs and HuskSync config files](gray)\n[• Current plugin system status information](gray)\n[• Information about your Java & Minecraft server environment](gray)\n[• A list of other currently installed plugins](gray)\n[To confirm, use:](#00fb9a) [/husksync dump confirm](#00fb9a italic show_text=&7Click to prepare dump run_command=/husksync dump confirm)'
system_dump_started: '[HuskSync](#00fb9a bold) [| Preparing system status dump, please wait…](#00fb9a)'
system_dump_ready: '[HuskSync](#00fb9a bold) [| System status dump prepared! Click to view:](#00fb9a)'
system_dump_confirm: '[HuskSync](#00fb9a bold) [| 准备系统转储? 这将包括:](#00fb9a)\n[• 您最新的服务器日志和 HuskSync 配置文件](gray)\n[• 当前插件系统状态信息](gray)\n[• 有关您的 Java Minecraft 服务器环境的信息](gray)\n[• 其他当前安装的插件列表](gray)\n[要确认, 请执行命令:](#00fb9a) [/husksync dump confirm](#00fb9a italic show_text=&7点击以准备转储 run_command=/husksync dump confirm)'
system_dump_started: '[HuskSync](#00fb9a bold) [| 正在准备系统状态转储,请稍候...](#00fb9a)'
system_dump_ready: '[HuskSync](#00fb9a bold) [| 系统状态转储已完成! 点击查看:](#00fb9a)'
error_invalid_syntax: '[错误:](#ff3300) [语法错误.用法:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&点击建议 suggest_command=%1%)'
error_invalid_player: '[错误:](#ff3300) [找不到这个名称的玩家.](#ff7e5e)'
error_invalid_data: '[错误:](#ff3300) [无法解压缩快照数据, 因为它无效或已损坏.](#ff7e5e) [(详情…)](gray show_text=&7⚠ %1%)'

View File

@@ -1,15 +1,16 @@
HuskSync supports the following versions of Minecraft. Since v3.7, you must download the correct version of HuskSync for your server:
| Minecraft | Latest HuskSync | Java Version | Platforms | Support Status |
|:---------------:|:---------------:|:------------:|:--------------|:-----------------------------|
| 1.21.4 | _latest_ | 21 | Paper, Fabric | ✅ **Active Release** |
| 1.21.3 | 3.7.1 | 21 | Paper, Fabric | 🗃️ Archived (December 2024) |
| 1.21.1 | _latest_ | 21 | Paper, Fabric | **November 2025** (LTS) |
| 1.20.6 | 3.6.8 | 17 | Paper | 🗃️ Archived (October 2024) |
| 1.20.4 | 3.6.8 | 17 | Paper | 🗃️ Archived (July 2024) |
| 1.20.1 | _latest_ | 17 | Paper, Fabric | ✅ **November 2025** (LTS) |
| 1.17.1 - 1.19.4 | 3.6.8 | 17 | Paper | 🗃️ Archived |
| 1.16.5 | 3.2.1 | 16 | Paper | 🗃️ Archived |
| Minecraft | Latest HuskSync | Java Version | Platforms | Support Status |
|:---------------:|:---------------:|:------------:|:--------------|:------------------------------|
| 1.21.5 | _latest_ | 21 | Paper | ✅ **Active Release** |
| 1.21.4 | _latest_ | 21 | Paper, Fabric | **November 2025** (Non-LTS) |
| 1.21.3 | 3.7.1 | 21 | Paper, Fabric | 🗃️ Archived (December 2024) |
| 1.21.1 | _latest_ | 21 | Paper, Fabric | ✅ **November 2025** (LTS) |
| 1.20.6 | 3.6.8 | 17 | Paper | 🗃️ Archived (October 2024) |
| 1.20.4 | 3.6.8 | 17 | Paper | 🗃️ Archived (July 2024) |
| 1.20.1 | _latest_ | 17 | Paper, Fabric | ✅ **November 2025** (LTS) |
| 1.17.1 - 1.19.4 | 3.6.8 | 17 | Paper | 🗃️ Archived |
| 1.16.5 | 3.2.1 | 16 | Paper | 🗃️ Archived |
HuskSync is primarily developed against the latest release. Old Minecraft versions are allocated a support channel based on popularity, mod support, etc:

View File

@@ -14,7 +14,7 @@ dependencies {
modImplementation include("net.kyori:adventure-platform-fabric:${fabric_adventure_platform_version}")
modImplementation include("me.lucko:fabric-permissions-api:${fabric_permissions_api_version}")
modImplementation include("eu.pb4:sgui:${fabric_sgui_version}")
modImplementation include("net.william278.uniform:uniform-fabric:1.3.1+${project.name}")
modImplementation include("net.william278.uniform:uniform-fabric:1.3.3+${project.name}")
modImplementation include("net.william278.toilet:toilet-fabric:1.0.12+${project.name}")
modImplementation "net.fabricmc.fabric-api:fabric-api:${fabric_api_version}"
@@ -27,9 +27,9 @@ dependencies {
compileOnly 'net.william278:DesertWell:2.0.4'
compileOnly 'org.jetbrains:annotations:26.0.2'
compileOnly 'org.projectlombok:lombok:1.18.36'
compileOnly 'org.projectlombok:lombok:1.18.38'
annotationProcessor 'org.projectlombok:lombok:1.18.36'
annotationProcessor 'org.projectlombok:lombok:1.18.38'
implementation include(project(path: ":common"))
project(":common").configurations.api.dependencies.each { dependency ->

View File

@@ -31,7 +31,7 @@ import net.fabricmc.api.DedicatedServerModInitializer;
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
import net.fabricmc.loader.api.FabricLoader;
import net.fabricmc.loader.api.ModContainer;
//#if MC==12104
//#if MC>=12104
import net.kyori.adventure.platform.modcommon.MinecraftServerAudiences;
//#else
//$$ import net.kyori.adventure.platform.fabric.FabricServerAudiences;
@@ -54,7 +54,6 @@ import net.william278.husksync.database.PostgresDatabase;
import net.william278.husksync.event.FabricEventDispatcher;
import net.william278.husksync.event.ModLoadedCallback;
import net.william278.husksync.hook.PlanHook;
import net.william278.husksync.listener.EventListener;
import net.william278.husksync.listener.FabricEventListener;
import net.william278.husksync.listener.LockedHandler;
import net.william278.husksync.migrator.Migrator;
@@ -101,6 +100,7 @@ public class FabricHuskSync implements DedicatedServerModInitializer, HuskSync,
private static final int VERSION1_21_1 = 3955;
private static final int VERSION1_21_3 = 4082;
private static final int VERSION1_21_4 = 4189; // Current
private static final int VERSION1_21_5 = 4323;
private final TreeMap<Identifier, Serializer<? extends Data>> serializers = Maps.newTreeMap(
SerializerRegistry.DEPENDENCY_ORDER_COMPARATOR
@@ -109,6 +109,7 @@ public class FabricHuskSync implements DedicatedServerModInitializer, HuskSync,
private final Map<String, Boolean> permissions = Maps.newHashMap();
private final List<Migrator> availableMigrators = Lists.newArrayList();
private final Set<UUID> lockedPlayers = Sets.newConcurrentHashSet();
private final Set<UUID> disconnectingPlayers = Sets.newConcurrentHashSet();
private final Map<UUID, FabricUser> playerMap = Maps.newConcurrentMap();
private Logger logger;
@@ -116,7 +117,7 @@ public class FabricHuskSync implements DedicatedServerModInitializer, HuskSync,
private MinecraftServer minecraftServer;
private boolean disabling;
private Gson gson;
//#if MC==12104
//#if MC>=12104
private MinecraftServerAudiences audiences;
//#else
//$$ private FabricServerAudiences audiences;
@@ -167,7 +168,7 @@ public class FabricHuskSync implements DedicatedServerModInitializer, HuskSync,
private void onEnable() {
// Audiences
//#if MC==12104
//#if MC>=12104
this.audiences = MinecraftServerAudiences.of(minecraftServer);
//#else
//$$ this.audiences = FabricServerAudiences.of(minecraftServer);
@@ -387,7 +388,10 @@ public class FabricHuskSync implements DedicatedServerModInitializer, HuskSync,
case "1.21", "1.21.1" -> VERSION1_21_1;
case "1.21.2", "1.21.3" -> VERSION1_21_3;
case "1.21.4" -> VERSION1_21_4;
//#if MC==12104
case "1.21.5" -> VERSION1_21_5;
//#if MC==12105
//$$ default -> VERSION1_21_5;
//#elseif MC==12104
default -> VERSION1_21_4;
//#elseif MC==12101
//$$ default -> VERSION1_21_1;

View File

@@ -54,7 +54,7 @@ import net.william278.husksync.FabricHuskSync;
import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.Adaptable;
import net.william278.husksync.config.Settings.SynchronizationSettings.AttributeSettings;
//#if MC==12104
//#if MC>=12104
import net.william278.husksync.mixins.HungerManagerMixin;
//#endif
import net.william278.husksync.user.FabricUser;
@@ -178,7 +178,7 @@ public abstract class FabricData implements Data {
@Override
public void apply(@NotNull FabricUser user, @NotNull FabricHuskSync plugin) throws IllegalStateException {
final ServerPlayerEntity player = user.getPlayer();
//#if MC==12104
//#if MC>=12104
player.playerScreenHandler.getCraftingInput().clear();
//#else
//$$ player.playerScreenHandler.clearCraftingSlots();
@@ -513,7 +513,7 @@ public abstract class FabricData implements Data {
// Apply teleport
try {
player.dismountVehicle();
//#if MC==12104
//#if MC>=12104
player.teleport(target, x, y, z, Set.of(), yaw, pitch, true);
//#else
//$$ player.teleport(target, x, y, z, yaw, pitch);
@@ -804,7 +804,7 @@ public abstract class FabricData implements Data {
@NotNull
public static FabricData.Hunger adapt(@NotNull ServerPlayerEntity player) {
final HungerManager hunger = player.getHungerManager();
//#if MC==12104
//#if MC>=12104
float exhaustion = ((HungerManagerMixin) hunger).getExhaustion();
//#else
//$$ float exhaustion = hunger.getExhaustion();
@@ -823,7 +823,7 @@ public abstract class FabricData implements Data {
final HungerManager hunger = player.getHungerManager();
hunger.setFoodLevel(foodLevel);
hunger.setSaturationLevel(saturation);
//#if MC==12104
//#if MC>=12104
((HungerManagerMixin) hunger).setExhaustion(exhaustion);
//#else
//$$ hunger.setExhaustion(exhaustion);

View File

@@ -228,7 +228,7 @@ public abstract class FabricSerializer {
@Nullable
private NbtCompound encodeNbt(@NotNull ItemStack item, @NotNull DynamicRegistryManager registryManager) {
try {
//#if MC==12104
//#if MC>=12104
return (NbtCompound) item.toNbt(registryManager);
//#elseif MC==12101
//$$ return (NbtCompound) item.encode(registryManager);

View File

@@ -34,29 +34,31 @@ public interface FabricUserDataHolder extends UserDataHolder {
@Override
default Optional<? extends Data> getData(@NotNull Identifier id) {
if (!id.isCustom()) {
try {
return switch (id.getKeyValue()) {
case "inventory" -> getInventory();
case "ender_chest" -> getEnderChest();
case "potion_effects" -> getPotionEffects();
case "advancements" -> getAdvancements();
case "location" -> getLocation();
case "statistics" -> getStatistics();
case "health" -> getHealth();
case "hunger" -> getHunger();
case "attributes" -> getAttributes();
case "experience" -> getExperience();
case "game_mode" -> getGameMode();
case "flight_status" -> getFlightStatus();
case "persistent_data" -> getPersistentData();
default -> throw new IllegalStateException(String.format("Unexpected data type: %s", id));
};
} catch (Throwable e) {
getPlugin().debug("Failed to get data for key: " + id.getKeyValue(), e);
}
if (id.isCustom()) {
return Optional.ofNullable(getCustomDataStore().get(id));
}
try {
return switch (id.getKeyValue()) {
case "inventory" -> getInventory();
case "ender_chest" -> getEnderChest();
case "potion_effects" -> getPotionEffects();
case "advancements" -> getAdvancements();
case "location" -> getLocation();
case "statistics" -> getStatistics();
case "health" -> getHealth();
case "hunger" -> getHunger();
case "attributes" -> getAttributes();
case "experience" -> getExperience();
case "game_mode" -> getGameMode();
case "flight_status" -> getFlightStatus();
case "persistent_data" -> getPersistentData();
default -> throw new IllegalStateException(String.format("Unexpected data type: %s", id));
};
} catch (Throwable e) {
getPlugin().debug("Failed to get data for key: " + id.asMinimalString(), e);
return Optional.empty();
}
return Optional.ofNullable(getCustomDataStore().get(id));
}
@Override

View File

@@ -126,7 +126,7 @@ public class FabricEventListener extends EventListener implements LockedHandler
return (cancelPlayerEvent(player.getUuid())) ? ActionResult.FAIL : ActionResult.PASS;
}
//#if MC==12104
//#if MC>=12104
private ActionResult handleItemInteract(PlayerEntity player, World world, Hand hand) {
return (cancelPlayerEvent(player.getUuid())) ? ActionResult.FAIL : ActionResult.PASS;
}

View File

@@ -17,7 +17,7 @@
* limitations under the License.
*/
//#if MC==12104
//#if MC>=12104
package net.william278.husksync.mixins;
import net.minecraft.entity.player.HungerManager;

View File

@@ -36,7 +36,7 @@ public class ServerWorldMixin {
@Shadow
private MinecraftServer server;
//#if MC==12104
//#if MC>=12104
@Inject(method = "savePersistentState", at = @At("HEAD"))
//#else
//$$ @Inject(method = "saveLevel", at = @At("HEAD"))

View File

@@ -25,7 +25,7 @@ import eu.pb4.sgui.api.elements.GuiElementInterface;
import eu.pb4.sgui.api.gui.SimpleGui;
import me.lucko.fabric.api.permissions.v0.Permissions;
import net.kyori.adventure.audience.Audience;
//#if MC==12104
//#if MC>=12104
import net.kyori.adventure.platform.modcommon.MinecraftServerAudiences;
//#else
//$$ import net.kyori.adventure.platform.fabric.FabricServerAudiences;
@@ -64,8 +64,9 @@ public class FabricUser extends OnlineUser implements FabricUserDataHolder {
}
@Override
public boolean isOffline() {
return player == null || player.isDisconnected();
public boolean hasDisconnected() {
return getPlugin().getDisconnectingPlayers().contains(getUuid())
|| player == null || player.isDisconnected();
}
@NotNull
@@ -79,7 +80,7 @@ public class FabricUser extends OnlineUser implements FabricUserDataHolder {
public void sendToast(@NotNull MineDown title, @NotNull MineDown description, @NotNull String iconMaterial,
@NotNull String backgroundType) {
plugin.log(Level.WARNING, "Toast notifications are deprecated. " +
"Please change your notification display slot to CHAT, ACTION_BAR or NONE.");
"Please change your notification display slot to CHAT, ACTION_BAR or NONE.");
this.sendActionBar(title);
}
@@ -106,7 +107,7 @@ public class FabricUser extends OnlineUser implements FabricUserDataHolder {
this.editable = editable;
// Set title, items
//#if MC==12104
//#if MC>=12104
this.setTitle(((MinecraftServerAudiences) plugin.getAudiences()).asNative(title.toComponent()));
//#else
//$$ this.setTitle(((FabricServerAudiences) plugin.getAudiences()).toNative(title.toComponent()));