9
0
mirror of https://github.com/WiIIiam278/HuskSync.git synced 2025-12-21 15:49:20 +00:00

Compare commits

..

410 Commits
3.0.1 ... 3.7.2

Author SHA1 Message Date
William
e04c19acf5 build: general dependency bump 2024-12-07 21:28:26 +00:00
William
1820a810f4 feat: add method for getting OnlineUser in common module 2024-12-07 20:58:40 +00:00
William
cedd12a048 feat: target Minecraft 1.21.4, replacing 1.21.3 2024-12-07 20:58:38 +00:00
dependabot[bot]
7967d00208 deps: bump commons-io:commons-io from 2.17.0 to 2.18.0 (#426)
Bumps commons-io:commons-io from 2.17.0 to 2.18.0.

---
updated-dependencies:
- dependency-name: commons-io:commons-io
  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>
2024-12-06 22:01:50 +00:00
dependabot[bot]
00a68be2ad deps: bump com.zaxxer:HikariCP from 6.2.0 to 6.2.1 (#427)
Bumps [com.zaxxer:HikariCP](https://github.com/brettwooldridge/HikariCP) from 6.2.0 to 6.2.1.
- [Changelog](https://github.com/brettwooldridge/HikariCP/blob/dev/CHANGES)
- [Commits](https://github.com/brettwooldridge/HikariCP/compare/HikariCP-6.2.0...HikariCP-6.2.1)

---
updated-dependencies:
- dependency-name: com.zaxxer:HikariCP
  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>
2024-12-06 22:01:41 +00:00
William278
da5d991d2a build: bump to 3.7.2 2024-11-21 16:16:48 +00:00
dependabot[bot]
c2f6d240ad deps: bump com.zaxxer:HikariCP from 6.1.0 to 6.2.0 (#422)
Bumps [com.zaxxer:HikariCP](https://github.com/brettwooldridge/HikariCP) from 6.1.0 to 6.2.0.
- [Changelog](https://github.com/brettwooldridge/HikariCP/blob/dev/CHANGES)
- [Commits](https://github.com/brettwooldridge/HikariCP/compare/HikariCP-6.1.0...HikariCP-6.2.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>
2024-11-21 16:09:56 +00:00
dependabot[bot]
4cde24c536 deps: bump org.projectlombok:lombok from 1.18.34 to 1.18.36 (#420)
Bumps [org.projectlombok:lombok](https://github.com/projectlombok/lombok) from 1.18.34 to 1.18.36.
- [Changelog](https://github.com/projectlombok/lombok/blob/master/doc/changelog.markdown)
- [Commits](https://github.com/projectlombok/lombok/compare/v1.18.34...v1.18.36)

---
updated-dependencies:
- dependency-name: org.projectlombok:lombok
  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>
2024-11-21 16:09:45 +00:00
dependabot[bot]
029617bc45 deps: bump com.gradleup.shadow from 8.3.4 to 8.3.5 (#423)
Bumps [com.gradleup.shadow](https://github.com/GradleUp/shadow) from 8.3.4 to 8.3.5.
- [Release notes](https://github.com/GradleUp/shadow/releases)
- [Commits](https://github.com/GradleUp/shadow/compare/8.3.4...8.3.5)

---
updated-dependencies:
- dependency-name: com.gradleup.shadow
  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>
2024-11-21 16:09:31 +00:00
William
0627fb20e4 refactor: adjust equals impl in Identifier 2024-11-15 12:11:02 +00:00
William278
bc1f983684 build: actually checkout 1.21.1 on 1.21.1 2024-11-14 17:11:27 +00:00
William278
31eb747c55 build: fix release script 2024-11-14 17:00:17 +00:00
William278
e8facf52ce fix: correct env in release file 2024-11-14 15:42:35 +00:00
William278
5ee4bdd644 fix: fix broken sendPacket mixin on Fabric 2024-11-14 15:33:26 +00:00
William278
92c371e201 fix: disable "other" gui actions.
Fixes an issue where you could double-click stack to collect unstacked items without edit perms.

Unfortunately this breaks pick block on creative without edit permission, but this is  considered a necessary compromise.
2024-11-14 15:06:46 +00:00
William278
d27278454a feat: warn if server name matches default, close #314 2024-11-14 14:58:43 +00:00
William278
16780c149c build: bump deps 2024-11-14 14:54:50 +00:00
William278
0445ba63bc fix: use correct map render logic, close #406 2024-11-14 14:38:11 +00:00
dependabot[bot]
b6aefd6f57 ci: bump mikepenz/action-junit-report from 4 to 5 (#412)
Bumps [mikepenz/action-junit-report](https://github.com/mikepenz/action-junit-report) from 4 to 5.
- [Release notes](https://github.com/mikepenz/action-junit-report/releases)
- [Commits](https://github.com/mikepenz/action-junit-report/compare/v4...v5)

---
updated-dependencies:
- dependency-name: mikepenz/action-junit-report
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-05 23:47:27 +00:00
William
f803af0225 feat: support newer Map data format, close #406 2024-11-04 21:49:24 +00:00
William
2675f4a377 fix: Paper event listener not registering events 2024-11-04 19:44:53 +00:00
William
03341c981f build: bump nbt-api to 2.14.0 2024-11-04 19:29:52 +00:00
William
38cc654167 fix: fixup some compiler warns 2024-11-01 22:44:29 +00:00
William
b347a8d060 fix: API not publishing, close #399 2024-11-01 22:32:03 +00:00
William
8733b86b45 [ci skip]: build 1.21.1 ver 2024-11-01 14:31:56 +00:00
William
eda8e72633 build: target Minecraft 1.21.3 2024-10-31 23:52:09 +00:00
William
c942a015d1 feat: start 1.21.3 2024-10-31 20:57:27 +00:00
William
c00265f1f9 fix: add getPlayerCustomDataStore impl on Fabric 2024-10-27 00:14:20 +01:00
dependabot[bot]
e303984dcf deps: bump org.jetbrains:annotations from 26.0.0 to 26.0.1 (#407)
Bumps [org.jetbrains:annotations](https://github.com/JetBrains/java-annotations) from 26.0.0 to 26.0.1.
- [Release notes](https://github.com/JetBrains/java-annotations/releases)
- [Changelog](https://github.com/JetBrains/java-annotations/blob/master/CHANGELOG.md)
- [Commits](https://github.com/JetBrains/java-annotations/compare/26.0.0...26.0.1)

---
updated-dependencies:
- dependency-name: org.jetbrains:annotations
  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>
2024-10-23 11:40:45 +01:00
Preva1l
b449b5dee6 fix: cache save causes, fix Fabric data save on shutdown (#405)
Co-authored-by: seeruk <wright.elliot@gmail.com>
2024-10-17 16:21:42 +01:00
dependabot[bot]
48f8c0c967 deps: bump org.jetbrains:annotations from 25.0.0 to 26.0.0 (#400)
Bumps [org.jetbrains:annotations](https://github.com/JetBrains/java-annotations) from 25.0.0 to 26.0.0.
- [Release notes](https://github.com/JetBrains/java-annotations/releases)
- [Changelog](https://github.com/JetBrains/java-annotations/blob/master/CHANGELOG.md)
- [Commits](https://github.com/JetBrains/java-annotations/compare/25.0.0...26.0.0)

---
updated-dependencies:
- dependency-name: org.jetbrains:annotations
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-15 16:20:30 +01:00
dependabot[bot]
f88c4c3e2c deps: bump org.ajoberstar.grgit from 5.2.2 to 5.3.0 (#401)
Bumps [org.ajoberstar.grgit](https://github.com/ajoberstar/grgit) from 5.2.2 to 5.3.0.
- [Release notes](https://github.com/ajoberstar/grgit/releases)
- [Commits](https://github.com/ajoberstar/grgit/compare/5.2.2...5.3.0)

---
updated-dependencies:
- dependency-name: org.ajoberstar.grgit
  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>
2024-10-15 16:20:23 +01:00
dependabot[bot]
e6273fa9a0 deps: bump org.junit.jupiter:junit-jupiter-params from 5.11.1 to 5.11.2 (#402)
Bumps [org.junit.jupiter:junit-jupiter-params](https://github.com/junit-team/junit5) from 5.11.1 to 5.11.2.
- [Release notes](https://github.com/junit-team/junit5/releases)
- [Commits](https://github.com/junit-team/junit5/compare/r5.11.1...r5.11.2)

---
updated-dependencies:
- dependency-name: org.junit.jupiter:junit-jupiter-params
  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>
2024-10-15 16:20:09 +01:00
dependabot[bot]
1ba5585d0d deps: bump org.junit.jupiter:junit-jupiter-api from 5.11.1 to 5.11.2 (#395)
Bumps [org.junit.jupiter:junit-jupiter-api](https://github.com/junit-team/junit5) from 5.11.1 to 5.11.2.
- [Release notes](https://github.com/junit-team/junit5/releases)
- [Commits](https://github.com/junit-team/junit5/compare/r5.11.1...r5.11.2)

---
updated-dependencies:
- dependency-name: org.junit.jupiter:junit-jupiter-api
  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>
2024-10-09 12:31:29 +01:00
dependabot[bot]
73547371ae deps: bump org.junit.jupiter:junit-jupiter-engine from 5.11.1 to 5.11.2 (#394)
Bumps [org.junit.jupiter:junit-jupiter-engine](https://github.com/junit-team/junit5) from 5.11.1 to 5.11.2.
- [Release notes](https://github.com/junit-team/junit5/releases)
- [Commits](https://github.com/junit-team/junit5/compare/r5.11.1...r5.11.2)

---
updated-dependencies:
- dependency-name: org.junit.jupiter:junit-jupiter-engine
  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>
2024-10-09 12:31:21 +01:00
dependabot[bot]
fca6825394 deps: bump org.jetbrains:annotations from 24.1.0 to 25.0.0 (#396)
Bumps [org.jetbrains:annotations](https://github.com/JetBrains/java-annotations) from 24.1.0 to 25.0.0.
- [Release notes](https://github.com/JetBrains/java-annotations/releases)
- [Changelog](https://github.com/JetBrains/java-annotations/blob/master/CHANGELOG.md)
- [Commits](https://github.com/JetBrains/java-annotations/compare/24.1.0...25.0.0)

---
updated-dependencies:
- dependency-name: org.jetbrains:annotations
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-09 12:31:15 +01:00
dependabot[bot]
53af114f44 deps: bump com.gradleup.shadow from 8.3.2 to 8.3.3 (#397)
Bumps [com.gradleup.shadow](https://github.com/GradleUp/shadow) from 8.3.2 to 8.3.3.
- [Release notes](https://github.com/GradleUp/shadow/releases)
- [Commits](https://github.com/GradleUp/shadow/compare/8.3.2...8.3.3)

---
updated-dependencies:
- dependency-name: com.gradleup.shadow
  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>
2024-10-09 12:31:07 +01:00
William
311cc85c92 docs: document incompatibility with /restart 2024-10-05 17:09:14 +01:00
William
099a258cf8 docs: update compatibility table 2024-10-03 15:06:22 +01:00
William
480f59a166 build: fix build, getEnv -> getProperty 2024-10-03 15:03:53 +01:00
William
45c2f5350f Merge remote-tracking branch 'origin/master' 2024-10-03 14:58:25 +01:00
William
ed88d77852 build: bump guava, junit and HikariCP 2024-10-03 14:58:12 +01:00
dependabot[bot]
e7fc9f015e deps: bump com.zaxxer:HikariCP from 5.1.0 to 6.0.0 (#388)
Bumps [com.zaxxer:HikariCP](https://github.com/brettwooldridge/HikariCP) from 5.1.0 to 6.0.0.
- [Changelog](https://github.com/brettwooldridge/HikariCP/blob/dev/CHANGES)
- [Commits](https://github.com/brettwooldridge/HikariCP/compare/HikariCP-5.1.0...HikariCP-6.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-03 14:57:06 +01:00
William
cabde9e8d8 fix: version_uuid instead of id in PSQL rotateSnapshots 2024-09-29 15:33:32 +01:00
William
4df7d2def4 fix: missing placeholder %s in postgres 2024-09-29 14:58:09 +01:00
William
59ed77c169 fix: Compat issues with Postgres queries, close #383 2024-09-29 14:32:57 +01:00
William
53da3bd40c docs: update README workflow badge 2024-09-29 14:21:13 +01:00
William
abdf8223fc refactor: adjust postgres statements 2024-09-29 13:35:28 +01:00
William
a5efeecad3 fix: NPE on startup due to compat check, close #384 2024-09-29 13:17:42 +01:00
William
4d26b24d13 Merge remote-tracking branch 'origin/master' 2024-09-28 19:01:53 +01:00
William
29b3a60c64 fix: sync default valued attributes 2024-09-28 19:01:46 +01:00
dependabot[bot]
da894f57c4 deps: bump com.gradleup.shadow from 8.3.0 to 8.3.2 (#380)
Bumps [com.gradleup.shadow](https://github.com/GradleUp/shadow) from 8.3.0 to 8.3.2.
- [Release notes](https://github.com/GradleUp/shadow/releases)
- [Commits](https://github.com/GradleUp/shadow/compare/8.3.0...8.3.2)

---
updated-dependencies:
- dependency-name: com.gradleup.shadow
  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>
2024-09-28 14:33:23 +01:00
dependabot[bot]
1bd703641b deps: bump org.bstats:bstats-bukkit from 3.0.3 to 3.1.0 (#379)
Bumps [org.bstats:bstats-bukkit](https://github.com/Bastian/bStats-Metrics) from 3.0.3 to 3.1.0.
- [Release notes](https://github.com/Bastian/bStats-Metrics/releases)
- [Commits](https://github.com/Bastian/bStats-Metrics/compare/v3.0.3...v3.1.0)

---
updated-dependencies:
- dependency-name: org.bstats:bstats-bukkit
  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>
2024-09-28 14:33:14 +01:00
dependabot[bot]
1b1d4c8e8d deps: bump commons-io:commons-io from 2.16.1 to 2.17.0 (#381)
Bumps commons-io:commons-io from 2.16.1 to 2.17.0.

---
updated-dependencies:
- dependency-name: commons-io:commons-io
  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>
2024-09-28 14:33:07 +01:00
Coded
842ec0e28d feat: add create_tables config option to disable automatic DDL operations (#377)
* Add config option for creating tables

* Move createTables config to a better position
2024-09-28 14:32:53 +01:00
William
2d5648408e build: account for differences in commit version names 2024-09-05 19:14:38 +01:00
William
41b3240741 ci: force copy files, make dir first 2024-09-05 17:50:11 +01:00
William
bc03e8f3e3 ci: recursive copy target jar folder 2024-09-05 17:46:11 +01:00
William
86799f4c08 ci: try copying outputs first 2024-09-05 17:43:06 +01:00
William
a3e004cf71 ci: adjust output paths 2024-09-05 17:37:43 +01:00
William
a7aeb1de21 fix: remove debug logging 2024-09-05 17:35:20 +01:00
William
1a703102c3 ci: More adjustments to CI script 2024-09-05 17:26:23 +01:00
William
368c68f42b fix: item and block stats not syncing, close #362 2024-09-05 17:25:03 +01:00
William
e191713bdc fix: attribute syncing setting bad base value 2024-09-05 17:07:50 +01:00
William
1604338498 ci: Use correct environment variable for workspace 2024-09-05 14:18:31 +01:00
William
c223797bf4 ci: Adjust glob build file output paths 2024-09-05 14:12:21 +01:00
William
9b10adc8e4 ci: fix version checking paths 2024-09-05 14:05:11 +01:00
William
5935f1ab5f ci: fix build version checking 2024-09-05 14:00:49 +01:00
William
3455b10a20 docs: update compatibility matrix 2024-09-05 13:58:32 +01:00
William
34e08b712d build: update CI to publish all active versions 2024-09-05 13:49:12 +01:00
William
605d314a58 refactor: make attributes allow-listed instead of deny-listed
This is a better default - as a number of attributes are primarily synced through other means (potions, items), or were applied from a context-sensitive action that does not warrant syncing across server contexts (sprinting, flying)
2024-09-05 13:37:53 +01:00
dependabot[bot]
daaf5147a7 deps: bump org.bstats:bstats-bukkit from 3.0.2 to 3.0.3 (#370)
Bumps [org.bstats:bstats-bukkit](https://github.com/Bastian/bStats-Metrics) from 3.0.2 to 3.0.3.
- [Release notes](https://github.com/Bastian/bStats-Metrics/releases)
- [Commits](https://github.com/Bastian/bStats-Metrics/compare/v3.0.2...v3.0.3)

---
updated-dependencies:
- dependency-name: org.bstats:bstats-bukkit
  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>
2024-09-03 22:20:26 +01:00
William
50eb9a7543 feat: add legacy upgrade command 2024-08-26 16:00:21 +01:00
William
7d8ef7b6b3 refactor: update map persistence logic
Stop using deprecated methods
2024-08-26 15:32:28 +01:00
William
347d2d0a8f refactor: adjust modifier slot group methods 2024-08-26 14:10:02 +01:00
William
bd560fcc99 refactor: use getter and setter for payload bytes in RedisMessage 2024-08-26 14:08:49 +01:00
William
b68aedc99a fix: correct compat version check 2024-08-26 14:08:25 +01:00
William
47373d8974 refactor: lock user before updating user data on command 2024-08-26 14:08:11 +01:00
William
a57b8df994 refactor: clean up 1.21.1 compatibility issues
Close #368
2024-08-26 13:44:19 +01:00
William
17235637a5 fix: exception loading compatibility config 2024-08-26 12:35:21 +01:00
William
cd5abd5a65 build(deps): bump dependencies 2024-08-25 21:05:17 +01:00
William
5c6631cdcf docs: clarify release channels in compat table 2024-08-25 21:03:19 +01:00
William
621afcd5c6 feat: support Minecraft 1.21.1 on Fabric 2024-08-25 20:58:26 +01:00
William
112a974a6c feat: Target Minecraft 1.21.1 with new release system 2024-08-25 20:45:25 +01:00
William
f9d46b4aff Merge remote-tracking branch 'origin/master' 2024-08-25 20:38:31 +01:00
William
dfd828bca1 feat: introduce new versioning & Minecraft compatibility system 2024-08-25 20:07:04 +01:00
dependabot[bot]
2df9fd897a deps: bump net.kyori:adventure-platform-bukkit from 4.3.3 to 4.3.4 (#360)
Bumps [net.kyori:adventure-platform-bukkit](https://github.com/KyoriPowered/adventure-platform) from 4.3.3 to 4.3.4.
- [Release notes](https://github.com/KyoriPowered/adventure-platform/releases)
- [Commits](https://github.com/KyoriPowered/adventure-platform/compare/v4.3.3...v4.3.4)

---
updated-dependencies:
- dependency-name: net.kyori:adventure-platform-bukkit
  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>
2024-08-14 13:39:00 +01:00
dependabot[bot]
ff2531539e deps: bump net.kyori:adventure-platform-api from 4.3.3 to 4.3.4 (#361)
Bumps [net.kyori:adventure-platform-api](https://github.com/KyoriPowered/adventure-platform) from 4.3.3 to 4.3.4.
- [Release notes](https://github.com/KyoriPowered/adventure-platform/releases)
- [Commits](https://github.com/KyoriPowered/adventure-platform/compare/v4.3.3...v4.3.4)

---
updated-dependencies:
- dependency-name: net.kyori:adventure-platform-api
  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>
2024-08-14 13:38:50 +01:00
William
52ec138273 fix: suppress map cursor paper exception 2024-08-11 12:52:23 +01:00
William
0f7a866652 test: bump test deps to 1.21.1 2024-08-11 12:25:43 +01:00
William
eeb52ac41e deps: bump item-nbt-api to 2.13.2
support MC 1.21.1
2024-08-11 12:25:03 +01:00
William
4c7ec9ec21 docs: update config file 2024-08-09 16:53:26 +01:00
William
2f9064c4c6 refactor: revert "disable attributes/potion effects by default" 2024-08-09 16:48:40 +01:00
William
5c234cdb1d feat: improve server version status text 2024-08-09 15:03:23 +01:00
William
7d8a74381b build: bump runtime dependencies 2024-08-09 14:49:53 +01:00
William
04a7793585 refactor: auto-reformat code 2024-08-09 14:43:54 +01:00
William
ea068529f6 fix: stop syncing ambient effects, close #289
Effects from beacons, conduits, and The Warden will no longer sync.
2024-08-09 14:39:56 +01:00
William
fead3df0d8 fix: add boot warning to fabric 2024-08-09 14:25:17 +01:00
William
0c5a42a344 fix: Cancel outbound PacketEvents packets, close #344 2024-08-09 14:22:53 +01:00
William
75a2378ea8 feat: deprecate Toast notifications 2024-08-09 14:19:34 +01:00
William
662fc96ad5 refactor: disable potion effects & attributes by default 2024-08-09 14:11:19 +01:00
Dong Heon Hee
f456443da0 Merge branch 'master' into master 2024-08-05 07:39:13 +09:00
William
07da1c04ce fix: don't apply <1.21 attribute modifiers on >1.21 servers 2024-08-02 18:07:28 +01:00
William
845abf370a fix: more tweaks to fix attribute issues 2024-07-28 18:07:34 +01:00
William
83b5209a75 fix: "attribute modifier already applied" error, close #348 2024-07-26 16:44:04 +01:00
William
8e9850dd19 refactor: make potion effects an optional dep of attributes 2024-07-26 16:35:43 +01:00
William
1d24209b68 feat: add attribute config, don't sync potion modifiers, close #349 2024-07-26 14:26:52 +01:00
William
da70a54d78 ci: add bones publishing to CI 2024-07-24 23:49:20 +01:00
동헌희
fc7330213a fix: build error due to rebase with master branch 2024-07-25 00:15:15 +09:00
동헌희
d8272ba52d docs: update minimum required mc&jvm version 2024-07-23 08:50:32 +09:00
동헌희
315f0eeb2f ci: Update jvm version(21) 2024-07-23 08:50:32 +09:00
동헌희
8e83617ac4 ci: Update required jvm version(21) 2024-07-23 08:50:32 +09:00
동헌희
212bb0beb8 fix: Forgot to update upgradeItemStacks 2024-07-23 08:50:32 +09:00
동헌희
c16231b12b feat: Support fabric with 1.21 2024-07-23 08:50:32 +09:00
동헌희
93f7294859 fix: Breaking chage in fabric API 2024-07-23 08:50:32 +09:00
Preva1l
32ac57e2a4 fix: cme on potion effect syncing (#354)
* Started impl for mongo

* fix silly mistake with postgresql

* fix: race condition
2024-07-21 15:14:48 +01:00
William
c949c976d6 fix: more checkout key debug logging 2024-07-21 01:11:44 +01:00
William
ab736829f2 refactor: clarify data syncer method names 2024-07-21 01:04:14 +01:00
William
4433926ce7 build: bump to 3.6.7 2024-07-19 17:35:25 +01:00
William
f819fd4d5e fix: Fabric thread exhaustion/deadlock causing crashes 2024-07-19 17:33:04 +01:00
Stampede
e7659255fe feat: add ModLoaded callback event on Fabric (#346)
Co-authored-by: William <will27528@gmail.com>
2024-07-14 17:09:26 +01:00
Code Toad
0dee2e8319 build: correct grgit null reference in git-less environments, close #345 (#347)
Issue Summary:
when sources are downloaded (not through git) the build fails because of a null reference.
Fix:
replace null reference with empty string

Co-authored-by: codetoad <softwareenginer@pm.me>
2024-07-14 17:08:03 +01:00
William
7b35c47315 fix: wrong syntax processing on husksync migrate set 2024-07-11 13:12:44 +01:00
William
5056a794d8 fix: Set execution scopes in commands 2024-07-06 14:19:04 +01:00
William
5e6068431a build: bump to 3.6.6 2024-07-06 14:11:34 +01:00
William
8d69508689 Merge remote-tracking branch 'origin/master' 2024-07-06 14:11:26 +01:00
William
efb6d8a7de deps: bump lombok and commons 2024-07-06 14:11:20 +01:00
William
79d9778378 deps: bump Uniform to 1.2.1 2024-07-06 14:10:27 +01:00
dependabot[bot]
6a6695e447 build(deps): bump certifi from 2023.7.22 to 2024.7.4 in /test (#337)
Bumps [certifi](https://github.com/certifi/python-certifi) from 2023.7.22 to 2024.7.4.
- [Commits](https://github.com/certifi/python-certifi/compare/2023.07.22...2024.07.04)

---
updated-dependencies:
- dependency-name: certifi
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-06 14:10:01 +01:00
William
8862e6cd70 feat: don't check if dependency is loaded 2024-06-24 19:53:13 +01:00
William
0b29de9efc fix: update documentation, help menu access for migrators 2024-06-23 15:49:28 +01:00
William
962cdfce0b build: bundle postgres in HuskSync for Fabric 2024-06-22 18:15:01 +01:00
William
0c527202e5 fix: NMS exceptions being thrown when applying modifiers
Spigot's validation for this is like my hoover: it sucks.
2024-06-22 18:07:53 +01:00
William
d4e33aa9d2 fix: ensure data version is passed to deserialize methods
Fixes an issue where upgraded stacks would only have a size of 1
2024-06-22 18:06:17 +01:00
William
2fcd58fc18 feat: correctly apply keyed attribute modifiers, close #326
We need to construct attributes with their key if possible to avoid stacking. Uses reflection :( to do this.

Also adds a bit of error checking to health scale syncing
2024-06-21 13:17:53 +01:00
William
3d10b2324f feat: update DataFixer 2024-06-21 11:56:49 +01:00
William
31419f3b97 deps: bump Item NBT API to 2.13.1 2024-06-21 11:56:01 +01:00
William
8105ac27fc deps: bump Uniform to 1.1.8
Fixes startup NPE fetching usage text
2024-06-19 12:49:55 +01:00
William
44f251a948 deps: bump Uniform to 1.1.7
Adds usage text to bukkit & legacy Paper commands
2024-06-19 12:45:50 +01:00
William
463e707d27 deps: bump Uniform to 1.1.6 2024-06-19 12:23:40 +01:00
William
2d85910744 deps: bump Uniform to 1.1.5 2024-06-18 23:47:33 +01:00
William
268b279fdf feat: add the ability to disable HuskSync commands 2024-06-18 13:26:21 +01:00
William
a8ca3314d8 refactor: minor userdata dump refactor 2024-06-18 13:20:24 +01:00
William
2bdd3dae37 fix: enable game mode syncing by default
not sure why this is off by default
2024-06-18 13:11:29 +01:00
William
e29564c4ad deps: bump Uniform to 1.1.4
Fixes namespaced-backed commands being missing
2024-06-18 13:02:58 +01:00
William
6b8bb23af9 fix: cleanup leftover todo 2024-06-18 12:34:56 +01:00
William
91bbe05851 fix: fix various Fabric issues
Adjusted a mixin
Fixed Uniform being relocated causing a ClassNotFound exception (it's a JiJ mod now)
2024-06-18 12:31:58 +01:00
William
8ed6869aad docs: update maven README badge 2024-06-18 01:03:08 +01:00
dependabot[bot]
7efdf0d329 build(deps): bump urllib3 from 2.0.7 to 2.2.2 in /test (#324)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.0.7 to 2.2.2.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.0.7...2.2.2)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-18 01:01:26 +01:00
William
49c32e3f98 build: adjust repos order 2024-06-18 00:59:56 +01:00
William
f0574527b9 build: bump gradle to 8.8, uniform to 1.1.3 2024-06-18 00:46:29 +01:00
William
ad510a8fca deps: bump uniform to 1.1.2 2024-06-17 23:07:17 +01:00
William
303b287705 deps: bump uniform to 1.1.1 2024-06-17 22:44:04 +01:00
William
549508b9c1 fix: shadow fabric, not implement! 2024-06-17 22:21:48 +01:00
William
6c8a577701 fix: suppress IncompatibleClassChangeError on paper
Paper plugins don't get run through bytecode fixups by Spigot's Commodore. Spigot changed InventoryView to an interface recently, which causes this to be thrown.
2024-06-17 22:14:42 +01:00
William
862177bec7 build: bump to 3.6.1 2024-06-17 21:43:33 +01:00
William
dbed4d83a2 deps: bump NBT-API to 2.13.1-SNAPSHOT
Fixes 1.21 support on Paper
2024-06-17 21:28:35 +01:00
William
aa2090d97a docs: remove brigadier tab completion 2024-06-17 21:19:32 +01:00
William
b168ede7c5 fix: locked maps in shulker boxes not saving, close #322 2024-06-17 21:18:05 +01:00
William
0e706d36c4 refactor: use Uniform for native command support (#323)
* refactor: use Uniform for commands

* refactor: remove commodore

* fix: update Uniform, fix commands

* fix: bump uniform, fix commands on fabric

* feat: use new Uniform command permission system

* test: target 1.21
2024-06-17 21:07:09 +01:00
William
69d68de5c0 build: adjust Fabric build to append MC version 2024-06-15 18:20:30 +01:00
William
3d5395e5ae refactor: Remove debug print statements 2024-06-15 18:16:56 +01:00
William
332c71f041 fix/fabric: fix first item slot not syncing 2024-06-15 14:16:03 +01:00
William
b9fbcd72dd fix/fabric: slightly adjust item applying 2024-06-15 13:55:38 +01:00
William
68897e6265 fix/fabric: fix way game mode is changed 2024-06-15 13:51:29 +01:00
William
04606a7c9a docs: improve setup instructions
Improve Mongo instructions & add advice for Pterodactyl self-hosts
2024-06-15 13:46:04 +01:00
Stampede
6286bbe2ad fix: mongo breaking due to mixed use of UUIDs and strings (#321)
All UUIDs are now read and written as actual UUID objects, which was before causing errors due to a mixed use of UUID objects and string representations.
2024-06-15 13:41:52 +01:00
William
24ba209f8f feat: support 1.21
Fixes attribute modifier syncing, adjust apache dep
2024-06-14 12:34:05 +01:00
William
05d588f681 fix: wrong syntax message on /userdata 2024-06-14 11:51:15 +01:00
William
9aa3606f54 build: update Item-NBT-API to support 1.21 2024-06-14 11:18:16 +01:00
William
fc05e4b17a fix: only MySQL being supported on Fabric 2024-06-10 15:22:36 +01:00
dependabot[bot]
7b2b47de83 deps: bump org.projectlombok:lombok from 1.18.30 to 1.18.32 (#319)
Bumps [org.projectlombok:lombok](https://github.com/projectlombok/lombok) from 1.18.30 to 1.18.32.
- [Changelog](https://github.com/projectlombok/lombok/blob/master/doc/changelog.markdown)
- [Commits](https://github.com/projectlombok/lombok/compare/v1.18.30...v1.18.32)

---
updated-dependencies:
- dependency-name: org.projectlombok:lombok
  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>
2024-06-10 11:20:40 +01:00
dependabot[bot]
be0b4e3397 deps: bump dev.triumphteam:triumph-gui from 3.1.7 to 3.1.10 (#318)
Bumps [dev.triumphteam:triumph-gui](https://github.com/TriumphTeam/triumph-gui) from 3.1.7 to 3.1.10.
- [Release notes](https://github.com/TriumphTeam/triumph-gui/releases)
- [Commits](https://github.com/TriumphTeam/triumph-gui/compare/3.1.7...3.1.10)

---
updated-dependencies:
- dependency-name: dev.triumphteam:triumph-gui
  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>
2024-06-10 11:20:32 +01:00
dependabot[bot]
dd1ba594de deps: bump org.jetbrains:annotations from 24.0.1 to 24.1.0 (#317)
Bumps [org.jetbrains:annotations](https://github.com/JetBrains/java-annotations) from 24.0.1 to 24.1.0.
- [Release notes](https://github.com/JetBrains/java-annotations/releases)
- [Changelog](https://github.com/JetBrains/java-annotations/blob/master/CHANGELOG.md)
- [Commits](https://github.com/JetBrains/java-annotations/compare/24.0.1...24.1.0)

---
updated-dependencies:
- dependency-name: org.jetbrains:annotations
  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>
2024-06-10 11:20:24 +01:00
William
89368778f3 feat: add support for Fabric targeting Minecraft 1.20.1 (#217)
* Upgrade the Fabric version and rewrite the code.

* Migrate the completed code of version 1.19.2.

* fabric: some events.

* Updated open source license to Apache 2.0.

* Add Plan analyzer support.

* Fix build.

* `UnsupportedOperationException`

* More fabric implementation work, update to v3's structure

* Suppress compiler warnings

* Add commands, adjust registration order

* Inventory and ender chest data/serializers

* Update license headers

* Fixup shaded library relocations

* Fix build

* Potion effects & location serializers

* Catch `Files.createDirectory(path);` in `#getDataFolder`

* Update fabric.mod.json metadata, correct icon

* Events for Fabric (#218)

* Added apache commons pool2 dependency

A NoClassDefFoundError would get thrown without this dependency. Relocation appears to not work very well either, so it has been excluded for now

* Added in Item Pickup and Drop events and mixins

* Update husksync.mixins.json

* Switch drop item event to using Network Handler mixin

* Implemented even more events

- Interact block (place too)
- Interact Entity
- Use Item
- Block Break
- Player damage
- Inventory Click (handles drops)
- Player Commands

* Re-implement the dropItem mixin

* Set dropItem mixin as cancellable

* deps: Include all bukkit runtime deps

* fix/fabric: Supply AudienceProvider to `ConsoleUser` constructor

* docs: credit Fabric porters :)

* fix: Item deserialization now working

* refactor: Remove inventory debug log

* docs: Update `fabric.mod.json`

* refactor: update with upstream changes

* fix: dangling JD comment

* fix: config file reference fixes

* refactor: optimize imports, fix relocation

* refactor: move tag references to common

* refactor: use lombok for data / serializer methods

* fix: bad annotating

* refactor: adjust callback formatting

* fabric: bump deps, refactor to match main branch

* fabric: more serializer type work

* feat: register more fabric data serializers

also fixes a compile issue on bukkit, and refactors the JSON serializer to be in the common module

* feat: implement remaining Fabric serializers

* feat: add on-the-fly DFU for Fabric

Now auto-upgrades item data to support version bumps. Also improved the schema a lil' bit.

* feat: add missing mixins

* feat: implement toKeep/toDrop option on Fabric

* feat: apply stats on sync

* build: append fabric MC version to file name

* feat: add HuskSync API support for Fabric

Also updates the docs

* refactor: fixup a deprecation in the wrong spot

* refactor: optimize fabric item serializing in-line with Bukkit

* feat: implement viewer GUIs on Fabric

* docs: Fabric is in Alpha for now

---------

Co-authored-by: hanbings <hanbings@hanbings.io>
Co-authored-by: Stampede <carterblowers01@gmail.com>
2024-06-09 22:41:37 +01:00
William
e3fb1762a1 fix: display correct NotRegisteredException cause 2024-06-09 14:47:26 +01:00
William
516c243df8 refactor: throw NotRegisteredException if API class provider is bad 2024-06-09 14:46:27 +01:00
William
b7aa75fcd5 docs: correct typos 2024-06-09 14:33:26 +01:00
小蔡
549f013e0f locales: update zh-tw.yml (#316)
* Update zh-tw.yml

I also corrected some redundant words.

* Update zh-tw.yml
2024-06-05 12:30:34 +01:00
dependabot[bot]
14c56af465 deps: bump net.kyori:adventure-platform-api from 4.3.2 to 4.3.3 (#311)
Bumps [net.kyori:adventure-platform-api](https://github.com/KyoriPowered/adventure-platform) from 4.3.2 to 4.3.3.
- [Release notes](https://github.com/KyoriPowered/adventure-platform/releases)
- [Commits](https://github.com/KyoriPowered/adventure-platform/compare/v4.3.2...v4.3.3)

---
updated-dependencies:
- dependency-name: net.kyori:adventure-platform-api
  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>
2024-06-04 20:19:41 +01:00
dependabot[bot]
bee01dd15a deps: bump net.kyori:adventure-platform-bukkit from 4.3.2 to 4.3.3 (#313)
Bumps [net.kyori:adventure-platform-bukkit](https://github.com/KyoriPowered/adventure-platform) from 4.3.2 to 4.3.3.
- [Release notes](https://github.com/KyoriPowered/adventure-platform/releases)
- [Commits](https://github.com/KyoriPowered/adventure-platform/compare/v4.3.2...v4.3.3)

---
updated-dependencies:
- dependency-name: net.kyori:adventure-platform-bukkit
  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>
2024-06-04 19:37:43 +01:00
dependabot[bot]
e97551e67e deps: bump com.google.guava:guava from 33.2.0-jre to 33.2.1-jre (#312)
Bumps [com.google.guava:guava](https://github.com/google/guava) from 33.2.0-jre to 33.2.1-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>
2024-06-04 19:37:26 +01:00
William
97023e8425 refactor: check that a dependency Plugin#isEnabled 2024-06-02 17:07:05 +01:00
William
4fa7106a46 refactor: gracefully handle missing data deps 2024-06-01 18:10:16 +01:00
William
e0b81e4c76 refactor: add serialization identifier dependencies for applying data (#309)
* refactor: add serialization identifier dependencies for applying data

* fix: correct issues with deterministic sync order

* refactor: adjust base data type dependencies

* refactor: cleanup imports/trim whitespace

* docs: Document Identifier dependencies

* feat: fix issues with health scaling
2024-06-01 15:35:08 +01:00
William
c4adec3082 refactor: make more resilient against invalid effect types
Spigot has a potion effect API lookup mismatch bug (SPIGOT-7674) due to the deprecated methods we use to support 1.17
2024-05-29 00:08:40 +01:00
William
107238360c build: bump to 3.5.3 2024-05-28 21:57:56 +01:00
William
6141adbdb9 fix: attribute base values not being applied
modifiers were being applied, but in cases where the base value was edited, this was not
2024-05-28 21:57:26 +01:00
dependabot[bot]
eaa2ed74a6 deps: bump com.github.retrooper.packetevents:spigot from 2.2.1 to 2.3.0 (#305)
Bumps com.github.retrooper.packetevents:spigot from 2.2.1 to 2.3.0.

---
updated-dependencies:
- dependency-name: com.github.retrooper.packetevents:spigot
  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>
2024-05-27 17:04:08 +01:00
William
44c652c452 Merge remote-tracking branch 'origin/master' 2024-05-27 01:52:24 +01:00
William
78cf6bff63 docs: add PacketEvents docs to config 2024-05-27 01:52:18 +01:00
William
8ad4158ec0 docs: document PacketEvents support 2024-05-27 01:52:08 +01:00
William
405e6d7162 deps: bump runtime dependencies 2024-05-27 01:50:27 +01:00
AlexDev_
cff1c8f982 feat: added PacketEvents support as ProtocolLib alternative (#296) 2024-05-27 01:46:44 +01:00
William
f43ca2f043 refactor: adjust BukkitKeyedAdapter logic, close #304 2024-05-27 01:43:47 +01:00
dependabot[bot]
3114ab1a62 deps: bump com.google.code.gson:gson from 2.10.1 to 2.11.0 (#302)
Bumps [com.google.code.gson:gson](https://github.com/google/gson) from 2.10.1 to 2.11.0.
- [Release notes](https://github.com/google/gson/releases)
- [Changelog](https://github.com/google/gson/blob/main/CHANGELOG.md)
- [Commits](https://github.com/google/gson/compare/gson-parent-2.10.1...gson-parent-2.11.0)

---
updated-dependencies:
- dependency-name: com.google.code.gson:gson
  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>
2024-05-27 01:11:02 +01:00
dependabot[bot]
2da9749b0c deps: bump net.kyori:adventure-api from 4.16.0 to 4.17.0 (#300)
Bumps [net.kyori:adventure-api](https://github.com/KyoriPowered/adventure) from 4.16.0 to 4.17.0.
- [Release notes](https://github.com/KyoriPowered/adventure/releases)
- [Commits](https://github.com/KyoriPowered/adventure/compare/v4.16.0...v4.17.0)

---
updated-dependencies:
- dependency-name: net.kyori:adventure-api
  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>
2024-05-27 01:10:54 +01:00
dependabot[bot]
d4d510e100 --- (#303)
updated-dependencies:
- dependency-name: requests
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-27 01:10:29 +01:00
Preva1l
550ea26097 fix: MongoDB duplicate user table entries & not updating cached username (#301) 2024-05-13 10:50:58 +01:00
Preva1l
2b1e72a42e fix: wrong type in PostgreSQL schema (#299)
* Started impl for mongo

* fix silly mistake with postgresql

* fix silly mistake with postgresql
2024-05-10 19:06:34 +01:00
William
75f8bee706 build: adjust version meta for release builds 2024-05-06 17:05:52 +01:00
William
a3b50a0bf5 fix: advancement messages being improperly canceled 2024-05-06 16:48:15 +01:00
dependabot[bot]
e9ab0909ce deps: bump com.google.guava:guava from 33.1.0-jre to 33.2.0-jre (#298)
Bumps [com.google.guava:guava](https://github.com/google/guava) from 33.1.0-jre to 33.2.0-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-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-06 16:28:05 +01:00
dependabot[bot]
1e91b4b4ce build(deps): bump tqdm from 4.66.1 to 4.66.3 in /test (#297)
Bumps [tqdm](https://github.com/tqdm/tqdm) from 4.66.1 to 4.66.3.
- [Release notes](https://github.com/tqdm/tqdm/releases)
- [Commits](https://github.com/tqdm/tqdm/compare/v4.66.1...v4.66.3)

---
updated-dependencies:
- dependency-name: tqdm
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-06 16:27:58 +01:00
William
043b51d812 test: update test suite to Paper 1.20.6 2024-05-01 12:35:19 +01:00
William
fa5cea2aa3 refactor: adjust way advancement messages are cleared, close #285 2024-05-01 12:30:18 +01:00
William
e35dcf3aad feat: Minecraft 1.20.5/6 support (#295)
* feat: start 1.20.5 update testing

nbt-api seems to work great already :)

* feat: add DFU support for legacy upgrade

Adds an optional overload to `deserialize` to support passing the MC Version of the snapshot data

* refactor: `clone` ItemStack[] bukkit data arrays, close #294

Don't perform async operations on mutable player data
2024-05-01 12:08:42 +01:00
IbanEtc
68ec79add6 locales: add French (fr-fr) locales, courtesy of IbanEtchep (#293)
* french translation

* locales: fix line spacing in `fr-fr`

* locales: credit French (fr-fr)

---------

Co-authored-by: William <will27528@gmail.com>
2024-04-23 16:56:45 +01:00
dependabot[bot]
70235963ba deps: bump org.apache.commons:commons-text from 1.11.0 to 1.12.0 (#291)
Bumps org.apache.commons:commons-text from 1.11.0 to 1.12.0.

---
updated-dependencies:
- dependency-name: org.apache.commons:commons-text
  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>
2024-04-23 16:50:24 +01:00
William
245fbec80c fix: wrong check in legacy stats loading 2024-04-19 14:39:26 +01:00
Nhan Le
4d1a465c03 fix: add Settings packet to allow list (#287)
fix player skin and chunk loading actions get cancelled
2024-04-16 10:57:34 +01:00
dependabot[bot]
07dc0b8c12 deps: bump commons-io:commons-io from 2.16.0 to 2.16.1 (#284)
Bumps commons-io:commons-io from 2.16.0 to 2.16.1.

---
updated-dependencies:
- dependency-name: commons-io:commons-io
  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>
2024-04-15 14:23:47 +01:00
William
525f15e65b locales: correct typo in error_invalid_data, fix #283 2024-04-14 16:44:26 +01:00
William
017d26673a refactor: adjust imports 2024-04-13 15:23:05 +01:00
jhqwqmc
087c787ec2 locales: update zh-cn.yml (#281)
* Update zh-cn.yml

* Update zh-cn.yml
2024-04-13 02:05:59 +01:00
William
7218390f65 feat: Add support for Folia (#280)
* feat: Add support for Folia

* feat: fix folia advancement stuff

* feat: fix double negation (whoops)
2024-04-12 17:48:41 +01:00
William
bd312c48ea refactor: Improve data validation, allow deletion of invalid snapshots (#279)
* feat: move validation to be on unpack

* refactor: add validation and handling for invalid data to UX

* fix: `runAfter` not firing on unpack failure

* locales: minor update to `data_list_item_invalid`
2024-04-12 16:56:45 +01:00
dependabot[bot]
e4cc792f54 build(deps): bump idna from 3.4 to 3.7 in /test (#278)
Bumps [idna](https://github.com/kjd/idna) from 3.4 to 3.7.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst)
- [Commits](https://github.com/kjd/idna/compare/v3.4...v3.7)

---
updated-dependencies:
- dependency-name: idna
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-12 16:53:28 +01:00
William
7941745ed0 docs: update config file docs 2024-04-12 15:21:03 +01:00
William
21f125c48a fix: bad cast in applyStat 2024-04-12 15:08:56 +01:00
William
18b8b958fe docs: update data snapshot API
Fix a few references to max health and update data types table
2024-04-11 19:22:21 +01:00
William
35c23c7970 refactor: remove some unused code, cleanup 2024-04-11 19:16:27 +01:00
William
4bb38f67d3 refactor: use registry for statistics
also updates BukkitKeyedAdapter methods to use the registries and moves to just using the Json serializer for Bukkit locations
2024-04-11 19:10:40 +01:00
William
98cf42065b refactor: slightly simplify checkout logic 2024-04-11 15:15:11 +01:00
William
328d4476aa docs: update sync features docs/faq 2024-04-11 14:55:11 +01:00
William
8293d767da feat: add config to skip certain attributes 2024-04-10 20:24:27 +01:00
William
7b8fb92737 fix: ICV modifying offline inventories via API, close #275 2024-04-10 20:16:09 +01:00
William
0f1cc2d24f docs: Attributes now hold max health 2024-04-10 19:39:49 +01:00
William
676ba7a10a feat: Add attribute syncing (#276)
* refactor: add attribute syncing

* fix: don't sync unmodified attributes

* fix: register json serializer for Attributes

* fix: improve Attribute API methods

* docs: update Sync Features

* refactor: make attributes a set

Because they're unique (by UUID)
2024-04-10 19:38:37 +01:00
William
82dc765f66 fix: NotNull-annotate PacketEvent 2024-04-10 17:55:40 +01:00
William
16cfbc9410 fix: remove debug messages 2024-04-10 17:55:13 +01:00
William
2b4c7e6c3d fix: missing ALLOWED_PACKETS calling desync 2024-04-10 17:54:33 +01:00
William
a03d540938 fix: DateTimeException when running /userdata 2024-04-10 17:54:15 +01:00
William
6bcb3e7908 fix: add flight_status to getData 2024-04-10 17:12:14 +01:00
William
facbda65a8 fix: ProtocolLib startup warnings 2024-04-10 17:11:24 +01:00
William
2f5ddf6164 feat: add ProtocolLib support for deeper-level packet cancellation (#274)
* feat: add support for ProtocolLib packet-level state cancelling

* refactor: move commands to event listener, document ProtocolLib support

* docs: make Setup less claustrophobic

* fix: remove `@Getter` on `PlayerPacketAdapter`

* build: add missing license headers

* fix: inaccessible method on Paper

* test: add ProtocolLib to network spin test

* fix: whoops I targeted the wrong packets

* fix: bad command disabled check logic

* fix: final protocollib adjustments
2024-04-10 17:09:53 +01:00
William
4dfbc0e32b fix: more gracefully handle platform mismatches 2024-04-10 15:41:23 +01:00
William
07d0376dd6 docs: fix grammar in config comment 2024-04-10 15:32:33 +01:00
William
d23ea087c1 refactor: Redis -> 𝓡𝓮𝓭𝓲𝓼 2024-04-10 14:41:45 +01:00
William
ea77f2d782 docs: update docs on getting flight status 2024-04-10 14:40:28 +01:00
William
ef3dc7e602 fix: bad null annotations on legacy conversion 2024-04-10 14:38:07 +01:00
William
3fe6245ddf docs: update README to reflect new DB support 2024-04-10 14:36:11 +01:00
William
a35e83a424 feat: Move flight status into its own data type, use lombok for data class (#273)
* refactor: use lombok, separate flight, close #191

* refactor: suppress some warnings

* refactor: suppress unused `from` warnings

* refactor: correct bad null-annotations on Items

* refactor: fix null annotation on `getStack`

* refactor: override methods for getting flight status

* docs: add deprecation docs for flight in gamemode data
2024-04-10 14:34:19 +01:00
William
be5d1128de docs: add note about Paper stats API 2024-04-07 12:48:04 +01:00
William
8463e1bb7a fix: Remove legacy MineDown calls 2024-04-07 12:47:08 +01:00
William
5456b232f0 fix: don't escape double underscores in text 2024-04-07 12:45:31 +01:00
William
b0e585841c refactor: use system locale for date formatting 2024-04-07 12:44:58 +01:00
William
cd298af5ae deps: bump deps, update MineDown, close #270 2024-04-07 12:40:04 +01:00
William
e19477aada refactor: remove redundant toString on debug 2024-04-07 12:35:45 +01:00
William
7f75b9a917 refactor: explicitly cancel ArmorStandManipulateEvent 2024-04-07 12:35:22 +01:00
William
819421492b Merge remote-tracking branch 'origin/master' 2024-04-07 12:35:06 +01:00
SnowCutieOwO
8f13a3955c docs: fix errors in API (#268)
* Update API.md

There's a little mistake and I found it and fixed. :)

* Update API.md

Fixed the wrong index
2024-04-07 12:33:10 +01:00
dependabot[bot]
73de0ff392 deps: bump commons-io:commons-io from 2.15.1 to 2.16.0 (#266)
Bumps commons-io:commons-io from 2.15.1 to 2.16.0.

---
updated-dependencies:
- dependency-name: commons-io:commons-io
  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>
2024-04-02 14:57:55 +01:00
William
93edb0de4c refactor: slightly adjust how quit cursor item dropping gets handled 2024-03-29 23:50:51 +00:00
dependabot[bot]
bb5ae0b741 deps: bump space.arim.morepaperlib:morepaperlib from 0.4.3 to 0.4.4 (#263)
Bumps space.arim.morepaperlib:morepaperlib from 0.4.3 to 0.4.4.

---
updated-dependencies:
- dependency-name: space.arim.morepaperlib:morepaperlib
  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>
2024-03-27 12:55:31 +00:00
dependabot[bot]
ccd7601a0e deps: bump org.projectlombok:lombok from 1.18.30 to 1.18.32 (#264)
Bumps [org.projectlombok:lombok](https://github.com/projectlombok/lombok) from 1.18.30 to 1.18.32.
- [Changelog](https://github.com/projectlombok/lombok/blob/master/doc/changelog.markdown)
- [Commits](https://github.com/projectlombok/lombok/compare/v1.18.30...v1.18.32)

---
updated-dependencies:
- dependency-name: org.projectlombok:lombok
  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>
2024-03-27 12:54:47 +00:00
dependabot[bot]
50d15e9580 deps: bump de.tr7zw:item-nbt-api from 2.12.2 to 2.12.3 (#265)
Bumps de.tr7zw:item-nbt-api from 2.12.2 to 2.12.3.

---
updated-dependencies:
- dependency-name: de.tr7zw:item-nbt-api
  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>
2024-03-27 12:54:37 +00:00
dependabot[bot]
aa1e8b8e95 deps: bump com.google.guava:guava from 33.0.0-jre to 33.1.0-jre (#260)
Bumps [com.google.guava:guava](https://github.com/google/guava) from 33.0.0-jre to 33.1.0-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-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-19 17:42:56 +00:00
William
3ff01f7bb3 build: bump to 3.4.1 2024-03-16 19:57:37 +00:00
William
93ab25bf44 deps: target ConfigLib on Maven Central 2024-03-16 19:57:06 +00:00
Preva1l
4c0addfd67 feat: PostgreSQL, Mongo Atlas & Replica Support (#255)
* Started impl for mongo

* added docs

* refactor of the mongo code, made mongodb artifacts download at run time, tested and working

* complete all change requests

* remove mongo and bson from relocations as they arnt needed

* changed the config

* updated docs

* not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null

* added postgres support (closes https://github.com/WiIIiam278/HuskSync/issues/212)

* add support for mongodb atlas, added atlas and postrgres to docs, update the config example in docs, also updates mongodb driver bc apparently i was special and very very out of data

* Rework how mongo connections are handled, **breaks config for mongo only**, allows for MongoDB Atlas, normal MongoDb AND MongoDB replica sets via the parameters in advanced mongo settings, added try and catch on all mongo operations so that it actually throws instead of a cutsie little warning

* small doc change

* whoops forgot to instantiate MongoCollectionHelper, and added missing step from docs for atlas users

* why thats a tad embarrassing (grammar mistake)

* add cluster id to `/husksync status`, shows "MongoDB Atlas" in status if using mongodb atlas

---------

Co-authored-by: William <will27528@gmail.com>
2024-03-16 12:50:26 +00:00
SnowCutieOwO
b77cf2524d build: fix grgit failsafe being unreachable (#258)
* Update build.gradle

* Update build.gradle

* Update build.gradle
2024-03-13 09:59:48 +00:00
dependabot[bot]
501ea3f609 deps: bump org.json:json from 20240205 to 20240303 (#254)
Bumps [org.json:json](https://github.com/douglascrockford/JSON-java) from 20240205 to 20240303.
- [Release notes](https://github.com/douglascrockford/JSON-java/releases)
- [Changelog](https://github.com/stleary/JSON-java/blob/master/docs/RELEASES.md)
- [Commits](https://github.com/douglascrockford/JSON-java/commits)

---
updated-dependencies:
- dependency-name: org.json:json
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-05 18:49:21 +00:00
William278
a93af95fd2 docs: update README 2024-03-02 15:58:18 +00:00
William278
39767c5cd0 build: bump to 3.4 2024-03-02 15:54:22 +00:00
William278
48f7037898 fix: update license headers 2024-03-02 15:52:54 +00:00
Preva1l
67dddf0cfa feat: Add support for MongoDB data storage (#250)
* Started impl for mongo

* added docs

* refactor of the mongo code, made mongodb artifacts download at run time, tested and working

* complete all change requests

* remove mongo and bson from relocations as they arnt needed

* changed the config

* updated docs

* not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null not null

---------

Co-authored-by: William <will27528@gmail.com>
2024-03-02 15:47:36 +00:00
William
eeb5e57c1e fix: shutdown not clearing cached data 2024-02-28 23:19:37 +00:00
dependabot[bot]
5a6ea2cffe deps: bump com.github.Exlll.ConfigLib:configlib-yaml (#251)
Bumps [com.github.Exlll.ConfigLib:configlib-yaml](https://github.com/Exlll/ConfigLib) from v4.4.0 to v4.5.0.
- [Release notes](https://github.com/Exlll/ConfigLib/releases)
- [Commits](https://github.com/Exlll/ConfigLib/compare/v4.4.0...v4.5.0)

---
updated-dependencies:
- dependency-name: com.github.Exlll.ConfigLib:configlib-yaml
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-27 13:45:37 +00:00
dependabot[bot]
07ddd34f8e deps: bump net.kyori:adventure-api from 4.15.0 to 4.16.0 (#252)
Bumps [net.kyori:adventure-api](https://github.com/KyoriPowered/adventure) from 4.15.0 to 4.16.0.
- [Release notes](https://github.com/KyoriPowered/adventure/releases)
- [Commits](https://github.com/KyoriPowered/adventure/compare/v4.15.0...v4.16.0)

---
updated-dependencies:
- dependency-name: net.kyori:adventure-api
  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>
2024-02-27 13:45:18 +00:00
dependabot[bot]
a0b86c298f deps: bump org.ajoberstar.grgit from 5.2.1 to 5.2.2 (#247)
Bumps [org.ajoberstar.grgit](https://github.com/ajoberstar/grgit) from 5.2.1 to 5.2.2.
- [Release notes](https://github.com/ajoberstar/grgit/releases)
- [Commits](https://github.com/ajoberstar/grgit/compare/5.2.1...5.2.2)

---
updated-dependencies:
- dependency-name: org.ajoberstar.grgit
  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>
2024-02-19 13:41:49 +00:00
William
6fbef032bc locales: update zh-tw by lin_ak90 2024-02-17 17:37:05 +00:00
William
318aacd432 refactor: minor tidy up 2024-02-17 15:48:09 +00:00
Timon Michel
ba1b2ff62e fix: improve event cancellation logic for better plugin compat (#246) 2024-02-17 15:43:32 +00:00
William278
67ef4888da fix: death save updating player 2024-02-17 14:55:19 +00:00
William278
a5d3015c6e feat: allow customizable save / update causes 2024-02-13 16:23:33 +00:00
William278
131a364f53 fix: cache not cleared on /userdata delete, close #245 2024-02-13 14:38:19 +00:00
William
19636d9447 refactor: optimize imports 2024-02-12 17:51:54 +00:00
William
f803a0b57b refactor: revert keys change 2024-02-12 17:51:40 +00:00
William
28afffe95e refactor/redis: use scan instead of keys 2024-02-12 17:19:05 +00:00
dependabot[bot]
c7e100a78a deps: bump org.json:json from 20231013 to 20240205 (#244)
Bumps [org.json:json](https://github.com/douglascrockford/JSON-java) from 20231013 to 20240205.
- [Release notes](https://github.com/douglascrockford/JSON-java/releases)
- [Changelog](https://github.com/stleary/JSON-java/blob/master/docs/RELEASES.md)
- [Commits](https://github.com/douglascrockford/JSON-java/commits)

---
updated-dependencies:
- dependency-name: org.json:json
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-12 10:02:57 +00:00
William
12e223618d refactor: data save event order processing, use new method in DataSyncer (#243)
* fix: fire DataSaveEvent before disconnect

* fix: revert rename `addSnapshot`

* docs: mention `addSnapshot` firing the API event

* refactor: use DataSyncer method for event saving, close #242

* fix: trailing semicolon
2024-02-11 15:37:03 +00:00
William278
f6773f4e68 build: bump to 3.3.2 2024-02-11 14:27:04 +00:00
William278
b9434a56e8 refactor: minor Bukkit platform refactors 2024-02-11 14:26:48 +00:00
William
325fac41bf deps: bump junit to 5.10.2 2024-02-05 15:38:44 +00:00
William
87377bffc1 docs: update FAQs 2024-02-05 10:38:00 +00:00
William
c6fb7fb10f fix: preserve order of saved items to keep, close #186 2024-02-02 23:01:41 +00:00
William
c2ae9bd20a build: bump to 3.3.1 2024-02-02 22:24:47 +00:00
William
e580c4f2bd fix: LOCKSTEP preventing offline inv updates, close #229 2024-02-02 22:24:27 +00:00
dependabot[bot]
dabd9bc57d ci: bump gradle/gradle-build-action from 2 to 3 (#235)
Bumps [gradle/gradle-build-action](https://github.com/gradle/gradle-build-action) from 2 to 3.
- [Release notes](https://github.com/gradle/gradle-build-action/releases)
- [Commits](https://github.com/gradle/gradle-build-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: gradle/gradle-build-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-31 09:45:44 +00:00
William
fa7f6f0d6e fix: exception when reading server defaults 2024-01-26 14:55:09 +00:00
William
267cf1ff35 fix: wrong URL on startup exception 2024-01-26 13:57:52 +00:00
William
08944ffd35 refactor: update a few config comments 2024-01-26 13:48:46 +00:00
William
c75114b858 deps: bump ConfigLib to 4.3.0 2024-01-26 13:40:46 +00:00
William
350a8b864d fix: bad ConfigProvider logic 2024-01-26 00:00:18 +00:00
William278
df0bd7a7cb refactor: actually don't use lombok for API just yet 2024-01-25 15:46:07 +00:00
William278
9fc9e8caf4 refactor: use lombok in a few other places 2024-01-25 15:44:36 +00:00
William278
2e3db2fffa refactor: use Guava methods in various places 2024-01-25 15:42:30 +00:00
William
530b3ef24d refactor: Migrate from BoostedYaml to Exll's ConfigLib (#233)
* feat: start work on moving to Exll's configlib

* refactor: Fully migrate to Exlll's configlib

* refactor: Optimize imports
2024-01-25 15:37:04 +00:00
William278
a9bd4dd2f0 build: stop trying to be clever with gradle publishing
if 'i aint readin allat' was a build scripting language
2024-01-24 23:34:47 +00:00
William278
85706d97c5 refactor: move unregister to common API module 2024-01-24 23:32:35 +00:00
William278
f7e3104e6b build: remove unnecessary "plugin" module 2024-01-24 23:30:39 +00:00
William278
f56d7f6113 docs: Fix API platforms section typo 2024-01-24 23:26:14 +00:00
William278
685431a40d api: add cross-platform API support 2024-01-24 23:25:37 +00:00
William278
9da3ff5281 build: Start minimizing built jars 2024-01-24 23:11:59 +00:00
William278
24453d0e1a build: Require Java 17, Minecraft 1.17.1 2024-01-24 23:06:25 +00:00
William278
280e90e297 refactor: use guard clause in thread unlock logic 2024-01-24 23:00:41 +00:00
Rubén
31920d056d refactor: Reconnect to Redis when connection lost (#230)
* Add redis reconnection

* Add separated method to handle thread unlock

* Add reconnection time constant
2024-01-22 12:53:56 +00:00
William278
6641e11fd9 fix: high latency redis environments firing data updates twice 2024-01-20 17:30:22 +00:00
William278
66bbde0b5d command: update translator credits in AboutMenu 2024-01-19 16:33:56 +00:00
WinTone01
7dde6423e4 Update tr-tr.yml (#228) 2024-01-19 16:32:16 +00:00
William278
0eac12e3f8 locales: Add id-id, courtesy of Wirayuda5620 2024-01-18 19:17:56 +00:00
Wirayuda5620
5df58e4ef9 Update HuskSyncCommand.java AboutMenu
hehe 😋
2024-01-19 00:36:59 +07:00
Wirayuda5620
4a6583d8bd Indonesian translation for HuskSync 2024-01-19 00:34:52 +07:00
jhqwqmc
059ee6f660 locales: Update zh-cn.yml (#224)
Correction
2024-01-13 13:06:58 +00:00
William
414246f243 fix: Handle Bukkit objects that don't fully implement Keyed 2023-12-26 14:57:40 +00:00
William
a3e269c00b docs: document /husksync status 2023-12-26 14:42:28 +00:00
William
bf9f29ffe9 refactor: Improve display of /husksync status 2023-12-26 14:41:39 +00:00
William
29bd2e1319 feat: Add /husksync status report menu 2023-12-26 14:28:41 +00:00
William
2475a9b3c6 docs: Fix license headers 2023-12-26 12:49:07 +00:00
William
2a52cc9086 ci: bump github-wiki-action to v4 2023-12-26 12:42:59 +00:00
William
237abf9698 deps: bump adventure-platform to 4.3.2 2023-12-26 12:41:24 +00:00
William
adbc264532 Merge remote-tracking branch 'origin/master' 2023-12-26 12:41:20 +00:00
jhqwqmc
f9cfec7d03 Update zh-cn.yml (#220) 2023-12-26 12:40:27 +00:00
William
29805bfe04 docs: bump to 3.2.1 2023-12-26 12:39:42 +00:00
William
8d2e5a6a52 fix: Enum#valueOf throwing on legacy stat-map conversion 2023-12-26 12:39:24 +00:00
William
d4f61bd646 refactor: catch Throwable, not Exception 2023-12-26 12:38:07 +00:00
William
55173be04b docs: More on updated default sync mode 2023-12-21 19:11:56 +00:00
William
e7078c9542 docs: Document updated default sync mode 2023-12-21 19:04:22 +00:00
William
2aa33b2f2c fix: Improve accuracy of max health syncing #148 2023-12-21 18:30:40 +00:00
William
972fee1bc7 fix: Fix flight syncing sometimes failing, close #206 2023-12-21 17:34:01 +00:00
William
efe34977b5 ci: Use wiki-action@v3 2023-12-21 17:31:25 +00:00
William
02ed9687ee deps: Bump runtime dependencies 2023-12-21 17:31:22 +00:00
William
08889a1739 docs: Bump to 3.2 (Redis key protocol changes) 2023-12-21 17:01:56 +00:00
William
9cf6d1eab6 refactor: change default sync mode to LOCKSTEP 2023-12-21 17:01:34 +00:00
William
33c2eb2237 refactor: Use cloud for server for HuskHomes consistency 2023-12-21 15:57:58 +00:00
William
299586aa86 refactor: Rename DATA_UPDATE -> LATEST_SNAPSHOT 2023-12-21 15:53:56 +00:00
William
05c988f2c7 refactor: Extend DATA_UPDATE Redis cache time on LOCKSTEP mode 2023-12-21 15:50:35 +00:00
William
8e0ad76968 refactor: Improve getUserCheckedOut debug log 2023-12-21 15:06:17 +00:00
William
4db162e78f refactor: Even more minor debug logging tweaks 2023-12-21 15:02:36 +00:00
William
272bc1278a refactor: More minor debug logging tweaks 2023-12-21 15:01:46 +00:00
William
35fdcf7106 refactor: Further improve debug log messages 2023-12-21 14:59:15 +00:00
William
48e087a3d7 refactor: Improve debug log wording for getUserCheckedOut 2023-12-21 14:55:49 +00:00
William
ca000197e4 refactor: Further improvements to debug messages 2023-12-21 14:50:22 +00:00
William
a6bab88cee refactor: Add debug log for listenForRedis timeout 2023-12-21 14:30:14 +00:00
William
f0c64df439 refactor: Improve debug logging messages 2023-12-21 14:25:38 +00:00
William
ac5ab56717 fix: Don't wrap saveUserData in runAsync twice 2023-12-21 13:24:52 +00:00
William
c2025350ba fix: Optimize imports 2023-12-19 22:06:29 +00:00
William
4c2bb5c6df fix: Get correct platform Audience for OnlineUsers 2023-12-19 22:06:13 +00:00
William
fb069296e1 refactor: Use native adventure implementation on Paper 2023-12-19 22:03:24 +00:00
Roman Alexander
22eedc8522 feat: Add support for Redis Sentinels (#216)
* Add support for Redis Sentinels

* Add some comments
2023-12-19 19:27:03 +00:00
William278
664c8c3352 Bump to 3.1.3 2023-12-12 13:30:00 +00:00
William
e7e6f9cfa7 test: bump test suite to 1.20.4 2023-12-10 17:02:18 +00:00
William278
5ec0f1b098 Support MC 1.20.4, improve timestamp exceptions 2023-12-10 15:33:38 +00:00
William
8fad075357 Bump to 3.1.2 2023-12-10 00:54:29 +00:00
William
83e27cca83 locales: Add Korean (ko-kr) courtesy of cada3141 2023-12-10 00:53:21 +00:00
William
729230a646 ci: Update deps, tidy workflow files 2023-12-10 00:51:11 +00:00
Joo200
029407613f locales: add new localization to de-de (#215) 2023-12-09 23:32:24 +00:00
Daniil Nartsissov
3d6ff7c30b Save cause localization support (#214) 2023-12-03 13:40:05 +00:00
Daniil Nartsissov
5833ce955f locales: add ru-ru localization (#211) 2023-12-02 19:05:33 +00:00
dependabot[bot]
b3a5091828 Bump commons-io:commons-io from 2.15.0 to 2.15.1 (#209)
Bumps commons-io:commons-io from 2.15.0 to 2.15.1.

---
updated-dependencies:
- dependency-name: commons-io:commons-io
  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>
Co-authored-by: William <will27528@gmail.com>
2023-12-02 15:18:45 +00:00
William
693209ff00 docs: v3 instead of v2 in MPDB migrator page 2023-12-02 15:18:16 +00:00
dependabot[bot]
5d1bd7c3a9 Bump org.jetbrains:annotations from 24.0.1 to 24.1.0 (#208)
Bumps [org.jetbrains:annotations](https://github.com/JetBrains/java-annotations) from 24.0.1 to 24.1.0.
- [Release notes](https://github.com/JetBrains/java-annotations/releases)
- [Changelog](https://github.com/JetBrains/java-annotations/blob/master/CHANGELOG.md)
- [Commits](https://github.com/JetBrains/java-annotations/compare/24.0.1...24.1.0)

---
updated-dependencies:
- dependency-name: org.jetbrains:annotations
  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>
2023-11-24 11:05:40 +00:00
WinTone01
7b8c75dbeb Create tr-tr.yml (#207) 2023-11-12 12:39:15 +00:00
William
b7a30bd6e9 [ci skip] update readme 2023-11-10 10:20:25 +00:00
William
2daf5fedef docs: Add BuiltByBit to sidebar 2023-11-09 18:32:09 +00:00
William
5fd40915d0 [ci skip] docs: Add builtbybit to README 2023-11-09 18:30:17 +00:00
dependabot[bot]
c49700e9ec Bump org.junit.jupiter:junit-jupiter-params from 5.10.0 to 5.10.1 (#205)
Bumps [org.junit.jupiter:junit-jupiter-params](https://github.com/junit-team/junit5) from 5.10.0 to 5.10.1.
- [Release notes](https://github.com/junit-team/junit5/releases)
- [Commits](https://github.com/junit-team/junit5/compare/r5.10.0...r5.10.1)

---
updated-dependencies:
- dependency-name: org.junit.jupiter:junit-jupiter-params
  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>
2023-11-07 14:13:19 +00:00
William
0f35331441 Bump runtime dependencies 2023-11-06 12:43:50 +00:00
dependabot[bot]
0153e14ce5 Bump org.junit.jupiter:junit-jupiter-engine from 5.10.0 to 5.10.1 (#202)
Bumps [org.junit.jupiter:junit-jupiter-engine](https://github.com/junit-team/junit5) from 5.10.0 to 5.10.1.
- [Release notes](https://github.com/junit-team/junit5/releases)
- [Commits](https://github.com/junit-team/junit5/compare/r5.10.0...r5.10.1)

---
updated-dependencies:
- dependency-name: org.junit.jupiter:junit-jupiter-engine
  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>
2023-11-06 12:40:49 +00:00
dependabot[bot]
419434bdca Bump org.junit.jupiter:junit-jupiter-api from 5.10.0 to 5.10.1 (#201)
Bumps [org.junit.jupiter:junit-jupiter-api](https://github.com/junit-team/junit5) from 5.10.0 to 5.10.1.
- [Release notes](https://github.com/junit-team/junit5/releases)
- [Commits](https://github.com/junit-team/junit5/compare/r5.10.0...r5.10.1)

---
updated-dependencies:
- dependency-name: org.junit.jupiter:junit-jupiter-api
  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>
2023-11-06 12:39:18 +00:00
dependabot[bot]
f1be4d2d88 Bump de.tr7zw:item-nbt-api from 2.12.0 to 2.12.1 (#200)
Bumps de.tr7zw:item-nbt-api from 2.12.0 to 2.12.1.

---
updated-dependencies:
- dependency-name: de.tr7zw:item-nbt-api
  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>
2023-11-06 12:39:03 +00:00
dependabot[bot]
c973dc5f05 Bump com.zaxxer:HikariCP from 5.0.1 to 5.1.0 (#199)
Bumps [com.zaxxer:HikariCP](https://github.com/brettwooldridge/HikariCP) from 5.0.1 to 5.1.0.
- [Changelog](https://github.com/brettwooldridge/HikariCP/blob/dev/CHANGES)
- [Commits](https://github.com/brettwooldridge/HikariCP/compare/HikariCP-5.0.1...HikariCP-5.1.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>
2023-11-06 12:38:17 +00:00
dependabot[bot]
b530941687 Bump dev.triumphteam:triumph-gui from 3.1.6 to 3.1.7 (#197) 2023-11-03 01:23:56 +00:00
Ceddix
c09fde4c36 Update German (de-de) locales, fix broken link in README (#196)
* updated the locales url

* updated German translation
2023-11-02 20:30:33 +00:00
dependabot[bot]
8d3beab145 Bump commons-io:commons-io from 2.14.0 to 2.15.0 (#193)
Bumps commons-io:commons-io from 2.14.0 to 2.15.0.

---
updated-dependencies:
- dependency-name: commons-io:commons-io
  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>
2023-11-02 14:48:18 +00:00
dependabot[bot]
cdf666bde6 Bump org.apache.commons:commons-text from 1.10.0 to 1.11.0 (#194)
Bumps org.apache.commons:commons-text from 1.10.0 to 1.11.0.

---
updated-dependencies:
- dependency-name: org.apache.commons:commons-text
  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>
2023-11-02 14:46:53 +00:00
William
350528e394 docs: add note about persisting data via the DataSaveEvent 2023-11-02 14:46:32 +00:00
dependabot[bot]
a1d3e5fddc Bump org.ajoberstar.grgit from 5.2.0 to 5.2.1 (#192)
Bumps [org.ajoberstar.grgit](https://github.com/ajoberstar/grgit) from 5.2.0 to 5.2.1.
- [Release notes](https://github.com/ajoberstar/grgit/releases)
- [Commits](https://github.com/ajoberstar/grgit/compare/5.2.0...5.2.1)

---
updated-dependencies:
- dependency-name: org.ajoberstar.grgit
  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>
2023-10-25 01:57:35 +01:00
William
e096e58c45 Bump to 3.1.1, bump jedis to 5.0.1 2023-10-19 00:15:44 +01:00
William
75eafe57e2 [ci skip] Fix API badge on README 2023-10-18 19:04:10 +01:00
dependabot[bot]
0005392cd3 Bump urllib3 from 2.0.6 to 2.0.7 in /test (#188)
Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/urllib3/urllib3/releases)
- [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
- [Commits](https://github.com/urllib3/urllib3/compare/2.0.6...2.0.7)

---
updated-dependencies:
- dependency-name: urllib3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-17 22:06:35 +01:00
dependabot[bot]
93913ca4ef Bump org.json:json from 20230618 to 20231013 (#187)
Bumps [org.json:json](https://github.com/douglascrockford/JSON-java) from 20230618 to 20231013.
- [Release notes](https://github.com/douglascrockford/JSON-java/releases)
- [Changelog](https://github.com/stleary/JSON-java/blob/master/docs/RELEASES.md)
- [Commits](https://github.com/douglascrockford/JSON-java/commits)

---
updated-dependencies:
- dependency-name: org.json:json
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-16 15:58:16 +01:00
William
aa09639e55 Fix persisted locked map banner rotation 2023-10-15 14:46:56 +01:00
William278
b205643fdd Fix duplicate cursor creation, close #185 2023-10-15 12:56:49 +01:00
Missing_Love
6fc827dedf Update zh-cn.yml (#184) 2023-10-14 01:14:32 +01:00
Missing_Love
b8aa1d9701 Update zh-cn.yml (#183)
Update Chinese Simplified Translation
2023-10-14 00:07:22 +01:00
William
2db3bb313f docs: Add v4.9.2 MPDB converter warning 2023-10-13 17:00:01 +01:00
William
4d23377a18 Ensure #setContents always sets correct size 2023-10-11 19:02:46 +01:00
William
51116cbdfb docs: Minor updates to links/legacy migration 2023-10-10 16:38:29 +01:00
dependabot[bot]
6831ce094d Bump dev.triumphteam:triumph-gui from 3.1.5 to 3.1.6 (#181) 2023-10-10 02:20:44 +01:00
William
289227e763 locales: Remove redundant comments in Japanese 2023-10-10 00:25:18 +01:00
William
3b8a9e4ed1 locales: Fix Dutch filename 2023-10-10 00:24:59 +01:00
William
7db3ed678f Paper plugin support, save player itemsToKeep rather than drops if not empty (#179)
* Paper plugin support, save itemsToKeep if present, close #172

* Fixup wrong packages, suppress a warning

* Update docs, add settings for death saving, reorganise config slightly

* Improve default server name lookup

* docs: Add note on Unsupported Versions

* docs: Minor Sync Modes tweaks
2023-10-10 00:10:06 +01:00
William
6d9e68a65b sync: Terminate syncer connection before redis 2023-10-08 23:02:27 +01:00
William
2c33f3b0b4 stats: Add exception handling for legacy materials 2023-10-08 23:01:34 +01:00
William
c002d86fc0 Persist extra-terrestrial locked maps to disk (#180)
* Persist extra-terrestrial maps to disk, close #157

* Fix issue writing map file caches

* Don't bother synchronously rendering maps
2023-10-08 12:10:26 +01:00
William
a384de8e42 Update docs and bump test suite to 1.20.1 2023-10-05 19:12:21 +01:00
William
cae17f6e68 Introduce new lockstep syncing system, modularize sync modes (#178)
* Start work on modular sync systems

* Add experimental lockstep sync system, close #69

* Refactor RedisMessageType enum

* Fixup lockstep syncing

* Bump to 3.1

* Update docs with details about the new Sync Modes

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

* Add server to data snapshot overview

* API: Add API for setting data syncers

* Fixup weird statistic matching logic
2023-10-05 18:05:02 +01:00
dependabot[bot]
03ca335293 Bump urllib3 from 2.0.4 to 2.0.6 in /test (#177) 2023-10-03 10:47:34 +01:00
Arno Keesman
c2b9e6c932 add Dutch translation (#176) 2023-10-02 20:58:28 +01:00
dependabot[bot]
518853c921 Bump commons-io:commons-io from 2.13.0 to 2.14.0 (#174)
Bumps commons-io:commons-io from 2.13.0 to 2.14.0.

---
updated-dependencies:
- dependency-name: commons-io:commons-io
  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>
Co-authored-by: William <will27528@gmail.com>
2023-10-02 15:01:18 +01:00
dependabot[bot]
fe9dda31bd Bump net.kyori:adventure-platform-bukkit from 4.3.0 to 4.3.1 (#175)
Bumps [net.kyori:adventure-platform-bukkit](https://github.com/KyoriPowered/adventure-platform) from 4.3.0 to 4.3.1.
- [Release notes](https://github.com/KyoriPowered/adventure-platform/releases)
- [Commits](https://github.com/KyoriPowered/adventure-platform/compare/v4.3.0...v4.3.1)

---
updated-dependencies:
- dependency-name: net.kyori:adventure-platform-bukkit
  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>
2023-10-02 14:59:58 +01:00
Galen Huang
0fd29bca57 Fixed an error in stats map caused by modded block/item being null (#171) 2023-09-26 10:33:37 +01:00
Namiu/うにたろう
37a671dae9 Update ja-jp.yml (#170) 2023-09-26 10:33:24 +01:00
William
c406f40898 Bump to 3.0.2 2023-09-23 23:34:46 +01:00
William
7561762c25 Fix relocation of com.fatboyindustrial lib 2023-09-23 22:27:12 +01:00
William
d245245083 Fix #get call when appling locked map data, Fix #169 2023-09-23 18:45:06 +01:00
William
2b55e129b3 Slightly improve BukkitData.Items#setContents method 2023-09-23 15:15:10 +01:00
William
0caec74436 Improve stat map resilience for modded block types 2023-09-23 14:08:53 +01:00
173 changed files with 11939 additions and 3989 deletions

View File

@@ -1,7 +1,22 @@
# Dependabot configuration file for GitHub # Dependabot configuration file for GitHub
version: 2 version: 2
updates: updates:
- package-ecosystem: "gradle" # See documentation for possible values # CI workflow action updates
directory: "/" # Location of package manifests - package-ecosystem: "github-actions"
directory: "/"
schedule: schedule:
interval: "daily" interval: "weekly"
commit-message:
prefix: "ci"
# Gradle package updates
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "deps"
ignore:
- dependency-name: 'org.spigotmc:spigot-api'
- dependency-name: 'org.papermc:paper-api'

1
.github/funding.yml vendored
View File

@@ -1,3 +1,4 @@
# Funding metadata for GitHub # Funding metadata for GitHub
github: WiIIiam278 github: WiIIiam278
custom: https://buymeacoff.ee/william278 custom: https://buymeacoff.ee/william278

View File

@@ -1,37 +0,0 @@
# Builds, tests the project with Gradle
name: CI Tests
on:
push:
branches: [ 'master' ]
paths-ignore:
- 'docs/**'
- 'workflows/**'
- 'README.md'
permissions:
contents: read
checks: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Build with Gradle
uses: gradle/gradle-build-action@v2
with:
arguments: build test publish
env:
SNAPSHOTS_MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
SNAPSHOTS_MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Publish Test Report
uses: mikepenz/action-junit-report@v3
if: success() || failure() # always run even if the previous step fails
with:
report_paths: '**/build/test-results/test/TEST-*.xml'

70
.github/workflows/ci_1.20.1.yml vendored Normal file
View File

@@ -0,0 +1,70 @@
name: CI Tests
on:
push:
branches: [ 'minecraft/1.20.1' ]
paths-ignore:
- 'docs/**'
- 'workflows/**'
- 'README.md'
permissions:
contents: read
checks: write
jobs:
build:
name: 'Build - 1.20.1'
runs-on: ubuntu-latest
steps:
- name: 'Setup JDK 21 📦'
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: 'Setup Gradle 8.8 🏗️'
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '8.8'
- name: 'Checkout for CI 🛎️'
uses: actions/checkout@v4
with:
ref: 'minecraft/1.20.1'
- name: '[Current - 1.20.1] Build 🛎️'
run: |
./gradlew clean build publish
env:
SNAPSHOTS_MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
SNAPSHOTS_MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: 'Publish Test Report 📊'
uses: mikepenz/action-junit-report@v5
if: success() || failure() # Continue on failure
with:
report_paths: '**/build/test-results/test/TEST-*.xml'
- name: 'Fetch Version String 📝'
run: |
echo "::set-output name=VERSION_NAME::$(./gradlew properties --no-daemon --console=plain -q | grep "^version:" | awk '{printf $2}')"
id: fetch-version
- name: 'Set Version Variable 📝'
run: |
echo "version_name=${{steps.fetch-version.outputs.VERSION_NAME}}" >> $GITHUB_ENV
- name: 'Publish to William278.net 🚀'
uses: WiIIiam278/bones-publish-action@v1
with:
api-key: ${{ secrets.BONES_API_KEY }}
project: 'husksync'
channel: 'alpha'
version: ${{ env.version_name }}
changelog: ${{ github.event.head_commit.message }}
distro-names: |
paper-1.20.1
fabric-1.20.1
distro-groups: |
paper
fabric
distro-descriptions: |
Paper 1.20.1
Fabric 1.20.1
files: |
target/HuskSync-Paper-${{ env.version_name }}+mc.1.20.1.jar
target/HuskSync-Fabric-${{ env.version_name }}+mc.1.20.1.jar

70
.github/workflows/ci_1.21.1.yml vendored Normal file
View File

@@ -0,0 +1,70 @@
name: CI Tests
on:
push:
branches: [ 'minecraft/1.21.1' ]
paths-ignore:
- 'docs/**'
- 'workflows/**'
- 'README.md'
permissions:
contents: read
checks: write
jobs:
build:
name: 'Build - 1.21.1'
runs-on: ubuntu-latest
steps:
- name: 'Setup JDK 21 📦'
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: 'Setup Gradle 8.8 🏗️'
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '8.8'
- name: 'Checkout for CI 🛎️'
uses: actions/checkout@v4
with:
ref: 'minecraft/1.21.1'
- name: '[Current - 1.21.1] Build 🛎️'
run: |
./gradlew clean build publish
env:
SNAPSHOTS_MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
SNAPSHOTS_MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: 'Publish Test Report 📊'
uses: mikepenz/action-junit-report@v5
if: success() || failure() # Continue on failure
with:
report_paths: '**/build/test-results/test/TEST-*.xml'
- name: 'Fetch Version String 📝'
run: |
echo "::set-output name=VERSION_NAME::$(./gradlew properties --no-daemon --console=plain -q | grep "^version:" | awk '{printf $2}')"
id: fetch-version
- name: 'Set Version Variable 📝'
run: |
echo "version_name=${{steps.fetch-version.outputs.VERSION_NAME}}" >> $GITHUB_ENV
- name: 'Publish to William278.net 🚀'
uses: WiIIiam278/bones-publish-action@v1
with:
api-key: ${{ secrets.BONES_API_KEY }}
project: 'husksync'
channel: 'alpha'
version: ${{ env.version_name }}
changelog: ${{ github.event.head_commit.message }}
distro-names: |
paper-1.21.1
fabric-1.21.1
distro-groups: |
paper
fabric
distro-descriptions: |
Paper 1.21.1
Fabric 1.21.1
files: |
target/HuskSync-Paper-${{ env.version_name }}+mc.1.21.1.jar
target/HuskSync-Fabric-${{ env.version_name }}+mc.1.21.1.jar

68
.github/workflows/ci_master.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
name: CI Tests
on:
push:
branches: [ 'master' ]
paths-ignore:
- 'docs/**'
- 'workflows/**'
- 'README.md'
permissions:
contents: read
checks: write
jobs:
build:
name: 'Build - 1.21.4'
runs-on: ubuntu-latest
steps:
- name: 'Setup JDK 21 📦'
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: 'Setup Gradle 8.8 🏗️'
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '8.8'
- name: 'Checkout for CI 🛎️'
uses: actions/checkout@v4
- name: '[Current - 1.21.4] Build 🛎️'
run: |
./gradlew clean build publish
env:
SNAPSHOTS_MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
SNAPSHOTS_MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: 'Publish Test Report 📊'
uses: mikepenz/action-junit-report@v5
if: success() || failure() # Continue on failure
with:
report_paths: '**/build/test-results/test/TEST-*.xml'
- name: 'Fetch Version String 📝'
run: |
echo "::set-output name=VERSION_NAME::$(./gradlew properties --no-daemon --console=plain -q | grep "^version:" | awk '{printf $2}')"
id: fetch-version
- name: 'Set Version Variable 📝'
run: |
echo "version_name=${{steps.fetch-version.outputs.VERSION_NAME}}" >> $GITHUB_ENV
- name: 'Publish to William278.net 🚀'
uses: WiIIiam278/bones-publish-action@v1
with:
api-key: ${{ secrets.BONES_API_KEY }}
project: 'husksync'
channel: 'alpha'
version: ${{ env.version_name }}
changelog: ${{ github.event.head_commit.message }}
distro-names: |
paper-1.21.4
fabric-1.21.4
distro-groups: |
paper
fabric
distro-descriptions: |
Paper 1.21.4
Fabric 1.21.4
files: |
target/HuskSync-Paper-${{ env.version_name }}+mc.1.21.4.jar
target/HuskSync-Fabric-${{ env.version_name }}+mc.1.21.4.jar

View File

@@ -1,4 +1,3 @@
# Carry out tests on pull requests
name: PR Tests name: PR Tests
on: on:
@@ -7,18 +6,25 @@ on:
permissions: permissions:
contents: read contents: read
checks: write
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - name: 'Checkout for CI 🛎'
- name: Set up JDK 17 uses: actions/checkout@v4
uses: actions/setup-java@v3 - name: 'Set up JDK 21 📦'
uses: actions/setup-java@v4
with: with:
java-version: '17' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
- name: Test Pull Request - name: 'Build with Gradle 🏗️'
uses: gradle/gradle-build-action@v2 uses: gradle/gradle-build-action@v3
with: with:
arguments: test arguments: test
- name: 'Publish Test Report 📊'
uses: mikepenz/action-junit-report@v5
if: success() || failure() # Continue on failure
with:
report_paths: '**/build/test-results/test/TEST-*.xml'

View File

@@ -1,33 +1,108 @@
# Builds, tests and publishes to maven when a release is published
name: Release Tests name: Release Tests
on: on:
release: release:
types: [ published ] types: [ 'published' ]
permissions: permissions:
contents: read contents: read
checks: write checks: write
jobs: jobs:
build: build:
name: 'Publish Release'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - name: 'Setup JDK 21 📦'
- name: Set up JDK 17 uses: actions/setup-java@v4
uses: actions/setup-java@v3
with: with:
java-version: '17' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
- name: Build with Gradle - name: 'Setup Gradle 8.8 🏗️'
uses: gradle/gradle-build-action@v2 uses: gradle/actions/setup-gradle@v4
with: with:
arguments: build test publish gradle-version: '8.8'
- name: '[Current - 1.21.4] Checkout for CI 🛎️'
uses: actions/checkout@v4
with:
path: '1_21_4'
- name: '[Non-LTS - 1.21.1] Checkout for CI 🛎️'
uses: actions/checkout@v4
with:
ref: 'minecraft/1.21.1'
path: '1_21_1'
- name: '[LTS - 1.20.1] Checkout for CI 🛎️'
uses: actions/checkout@v4
with:
ref: 'minecraft/1.20.1'
path: '1_20_1'
- name: '[Current - 1.21.4] Build 🛎️'
run: |
mkdir target
cd 1_21_4
./gradlew clean build publish -Dforce-hide-version-meta=1
cp -rf target/* ../target/
cd ..
env: env:
RELEASES_MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} RELEASES_MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
RELEASES_MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} RELEASES_MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Publish Test Report - name: '[Non-LTS - 1.21.1] Build 🛎️'
uses: mikepenz/action-junit-report@v3 run: |
if: success() || failure() # always run even if the previous step fails cd 1_21_1
./gradlew clean build publish -Dforce-hide-version-meta=1
cp -rf target/* ../target/
cd ..
env:
RELEASES_MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
RELEASES_MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: '[LTS - 1.20.1] Build 🛎️'
run: |
cd 1_20_1
./gradlew clean build publish -Dforce-hide-version-meta=1
cp -rf target/* ../target/
cd ..
env:
RELEASES_MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
RELEASES_MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: 'Publish Test Report 📊'
uses: mikepenz/action-junit-report@v5
if: success() || failure() # Continue on failure
with: with:
report_paths: '**/build/test-results/test/TEST-*.xml' report_paths: '**/build/test-results/test/TEST-*.xml'
- name: 'Publish to William278.net 🚀'
uses: WiIIiam278/bones-publish-action@v1
with:
api-key: ${{ secrets.BONES_API_KEY }}
project: 'husksync'
channel: 'release'
version: ${{ github.event.release.tag_name }}
changelog: ${{ github.event.release.body }}
distro-names: |
paper-1.21.4
fabric-1.21.4
paper-1.21.1
fabric-1.21.1
paper-1.20.1
fabric-1.20.1
distro-groups: |
paper
fabric
paper
fabric
paper
fabric
distro-descriptions: |
Paper 1.21.4
Fabric 1.21.4
Paper 1.21.1
Fabric 1.21.1
Paper 1.20.1
Fabric 1.20.1
files: |
target/HuskSync-Paper-${{ github.event.release.tag_name }}+mc.1.21.4.jar
target/HuskSync-Fabric-${{ github.event.release.tag_name }}+mc.1.21.4.jar
target/HuskSync-Paper-${{ github.event.release.tag_name }}+mc.1.21.1.jar
target/HuskSync-Fabric-${{ github.event.release.tag_name }}+mc.1.21.1.jar
target/HuskSync-Paper-${{ github.event.release.tag_name }}+mc.1.20.1.jar
target/HuskSync-Fabric-${{ github.event.release.tag_name }}+mc.1.20.1.jar

View File

@@ -1,4 +1,3 @@
# Update the GitHub Wiki documentation when a push is made to docs/
name: Update Docs name: Update Docs
on: on:
@@ -14,15 +13,13 @@ permissions:
contents: write contents: write
jobs: jobs:
deploy-wiki: update-docs:
name: 'Update Docs'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: 'Checkout Code' - name: 'Checkout for CI 🛎️'
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: 'Push Changes to Wiki' - name: 'Push Docs to Github Wiki 📄️'
uses: Andrew-Chen-Wang/github-wiki-action@v3 uses: Andrew-Chen-Wang/github-wiki-action@v4
env: with:
WIKI_DIR: 'docs/' path: 'docs'
GH_TOKEN: ${{ github.token }}
GH_MAIL: 'actions@github.com'
GH_NAME: 'github-actions[bot]'

View File

@@ -1,19 +0,0 @@
#!/bin/bash
JV=$(java -version 2>&1 >/dev/null | head -1)
echo "$JV" | sed -E 's/^.*version "([^".]*)\.[^"]*".*$/\1/'
if [ "$JV" != 16 ]; then
case "$1" in
install)
echo "installing sdkman..."
curl -s "https://get.sdkman.io" | bash
source ~/.sdkman/bin/sdkman-init.sh
sdk install java 16.0.1-open
;;
use)
echo "must source ~/.sdkman/bin/sdkman-init.sh"
exit 1
;;
esac
fi

View File

@@ -1,12 +1,12 @@
<!--suppress ALL --> <!--suppress ALL -->
<p align="center"> <p align="center">
<img src="images/banner.png" alt="HuskSync" /> <img src="images/banner.png" alt="HuskSync" />
<a href="https://github.com/WiIIiam278/HuskSync/actions/workflows/ci.yml"> <a href="https://github.com/WiIIiam278/HuskSync/actions/workflows/ci_master.yml">
<img src="https://img.shields.io/github/actions/workflow/status/WiIIiam278/HuskSync/ci.yml?branch=master&logo=github"/> <img src="https://img.shields.io/github/actions/workflow/status/WiIIiam278/HuskSync/ci_master.yml?branch=master&logo=github"/>
</a> </a>
<a href="https://jitpack.io/#net.william278/HuskSync"> <a href="https://repo.william278.net/#/releases/net/william278/husksync/">
<img src="https://img.shields.io/jitpack/version/net.william278/HuskSync?color=%2300fb9a&label=api&logo=gradle" /> <img src="https://repo.william278.net/api/badge/latest/releases/net/william278/husksync/husksync-common?color=00fb9a&name=Maven&prefix=v" />
</a> </a>
<a href="https://discord.gg/tVYhJfyDWG"> <a href="https://discord.gg/tVYhJfyDWG">
<img src="https://img.shields.io/discord/818135932103557162.svg?label=&logo=discord&logoColor=fff&color=7389D8&labelColor=6A7EC2" /> <img src="https://img.shields.io/discord/818135932103557162.svg?label=&logo=discord&logoColor=fff&color=7389D8&labelColor=6A7EC2" />
</a> </a>
@@ -26,7 +26,7 @@
</p> </p>
<br/> <br/>
**HuskSync** is a modern, cross-server player data synchronization system that enables the comprehensive synchronization of your user's data across multiple proxied servers. It does this by making use of Redis and MySQL to optimally cache data while players change servers. **HuskSync** is a modern, cross-server player data synchronization system that enables the comprehensive synchronization of your user's data across multiple proxied servers. It does this by making use of Redis and a MySQL/Mongo/PostgreSQL to optimally cache data while players change servers.
## Features ## Features
**⭐ Seamless synchronization** &mdash; Utilises optimised Redis caching when players change server to sync player data super quickly for a seamless experience. **⭐ Seamless synchronization** &mdash; Utilises optimised Redis caching when players change server to sync player data super quickly for a seamless experience.
@@ -43,16 +43,37 @@
**Ready?** [It's syncing time!](https://william278.net/docs/husksync/setup) **Ready?** [It's syncing time!](https://william278.net/docs/husksync/setup)
## Setup ## Compatibility
Requires a MySQL (v8.0+) database, a Redis (v5.0+) server and any number of Spigot-based 1.16.5+ Minecraft servers, running Java 16+. 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:
1. Place the plugin jar file in the /plugins/ directory of each Spigot server. You do not need to install HuskSync as a proxy plugin. | 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 2024** (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:
* Long Term Support (LTS) &ndash; Supported for up to 12-18 months
* Non-Long Term Support (Non-LTS) &ndash; Supported for 3-6 months
Verify your purchase on Discord and [Download HuskSync](https://william278.net/project/husksync#download) for your server.
## Setup
Requires a MySQL/Mongo/PostgreSQL database, a Redis (v5.0+) server and a network of Spigot or Fabric Minecraft servers (see [Compatibility](#compatibility)).
1. Place the plugin jar file in the `/plugins` or `/mods` directory of each Spigot/Fabric server. You do not need to install HuskSync as a proxy plugin.
2. Start, then stop every server to let HuskSync generate the config file. 2. Start, then stop every server to let HuskSync generate the config file.
3. Navigate to the HuskSync config file on each server (~/plugins/HuskSync/config.yml) and fill in both the MySQL and Redis database credentials. 3. Navigate to the HuskSync config file on each server and fill in both your database and Redis server credentials.
4. Start every server again and synchronization will begin. 4. Start every server again and synchronization will begin.
## Development ## Development
To build HuskSync, simply run the following in the root of the repository: To build HuskSync, simply run the following in the root of the repository (building requires Java 21). Builds will be output in `/target`:
```bash ```bash
./gradlew clean build ./gradlew clean build
@@ -66,17 +87,17 @@ HuskSync is licensed under the Apache 2.0 license.
Contributions to the project are welcome&mdash;feel free to open a pull request with new features, improvements and/or fixes! Contributions to the project are welcome&mdash;feel free to open a pull request with new features, improvements and/or fixes!
### Support ### Support
Due to its complexity, official support for HuskSync is provided through a paid model. This means that support is only available to users who have purchased a license to the plugin from Spigot, Polymart, or Craftaro and have provided proof of purchase. Please join our Discord server if you have done so and need help! Due to its complexity, official binaries and customer support for HuskSync is provided through a paid model. This means that support is only available to users who have purchased a license to the plugin from Spigot, Polymart, or BuiltByBit and have provided proof of purchase. Please join our Discord server if you have done so and need help!
### Translations ### Translations
Translations of the plugin locales are welcome to help make the plugin more accessible. Please submit a pull request with your translations as a `.yml` file. Translations of the plugin locales are welcome to help make the plugin more accessible. Please submit a pull request with your translations as a `.yml` file.
- [Locales Directory](https://github.com/WiIIiam278/HuskSync/tree/master/common/src/main/resources/languages) - [Locales Directory](https://github.com/WiIIiam278/HuskSync/tree/master/common/src/main/resources/locales)
- [English Locales](https://github.com/WiIIiam278/HuskSync/tree/master/common/src/main/resources/languages/en-gb.yml) - [English Locales](https://github.com/WiIIiam278/HuskSync/tree/master/common/src/main/resources/locales/en-gb.yml)
## Links ## Links
- [Docs](https://william278.net/docs/husksync/) &mdash; Read the plugin documentation! - [Docs](https://william278.net/docs/husksync/) &mdash; Read the plugin documentation!
- [Spigot](https://www.spigotmc.org/resources/husksync.97144/) &mdash; View the Spigot resource page (Also: [Polymart](https://polymart.org/resource/husksync.1634), [Craftaro](https://craftaro.com/marketplace/product/husksync.758)) - [Spigot](https://www.spigotmc.org/resources/husksync.97144/) &mdash; View the Spigot resource page (Also: [Polymart](https://polymart.org/resource/husksync.1634), [Craftaro](https://craftaro.com/marketplace/product/husksync.758), [BuiltByBit](https://builtbybit.com/resources/husksync.34956/))
- [Issues](https://github.com/WiIIiam278/HuskSync/issues) &mdash; File a bug report or feature request - [Issues](https://github.com/WiIIiam278/HuskSync/issues) &mdash; File a bug report or feature request
- [Discord](https://discord.gg/tVYhJfyDWG) &mdash; Get help, ask questions (Purchase required) - [Discord](https://discord.gg/tVYhJfyDWG) &mdash; Get help, ask questions (Purchase required)
- [bStats](https://bstats.org/plugin/bukkit/HuskSync%20-%20Bukkit/13140) &mdash; View plugin metrics - [bStats](https://bstats.org/plugin/bukkit/HuskSync%20-%20Bukkit/13140) &mdash; View plugin metrics

View File

@@ -1,7 +1,10 @@
import org.apache.tools.ant.filters.ReplaceTokens
plugins { plugins {
id 'com.github.johnrengelman.shadow' version '8.1.1' id 'com.gradleup.shadow' version '8.3.5'
id 'org.cadixdev.licenser' version '0.6.1' apply false id 'org.cadixdev.licenser' version '0.6.1' apply false
id 'org.ajoberstar.grgit' version '5.2.0' id 'fabric-loom' version "$fabric_loom_version" apply false
id 'org.ajoberstar.grgit' version '5.3.0'
id 'maven-publish' id 'maven-publish'
id 'java' id 'java'
} }
@@ -15,42 +18,77 @@ ext {
set 'version', version.toString() set 'version', version.toString()
set 'description', description.toString() set 'description', description.toString()
set 'minecraft_version', minecraft_version.toString()
set 'jedis_version', jedis_version.toString() set 'jedis_version', jedis_version.toString()
set 'mysql_driver_version', mysql_driver_version.toString() set 'mysql_driver_version', mysql_driver_version.toString()
set 'mariadb_driver_version', mariadb_driver_version.toString() set 'mariadb_driver_version', mariadb_driver_version.toString()
set 'postgres_driver_version', postgres_driver_version.toString()
set 'mongodb_driver_version', mongodb_driver_version.toString()
set 'snappy_version', snappy_version.toString() set 'snappy_version', snappy_version.toString()
} }
import org.apache.tools.ant.filters.ReplaceTokens publishing {
repositories {
if (System.getenv("RELEASES_MAVEN_USERNAME") != null) {
maven {
name = "william278-releases"
url = "https://repo.william278.net/releases"
credentials {
username = System.getenv("RELEASES_MAVEN_USERNAME")
password = System.getenv("RELEASES_MAVEN_PASSWORD")
}
authentication {
basic(BasicAuthentication)
}
}
}
if (System.getenv("SNAPSHOTS_MAVEN_USERNAME") != null) {
maven {
name = "william278-snapshots"
url = "https://repo.william278.net/snapshots"
credentials {
username = System.getenv("SNAPSHOTS_MAVEN_USERNAME")
password = System.getenv("SNAPSHOTS_MAVEN_PASSWORD")
}
authentication {
basic(BasicAuthentication)
}
}
}
}
}
allprojects { allprojects {
apply plugin: 'com.github.johnrengelman.shadow' apply plugin: 'com.gradleup.shadow'
apply plugin: 'org.cadixdev.licenser' apply plugin: 'org.cadixdev.licenser'
apply plugin: 'java' apply plugin: 'java'
compileJava.options.encoding = 'UTF-8' compileJava.options.encoding = 'UTF-8'
compileJava.options.compilerArgs += ['-Xlint:unchecked', '-Xlint:deprecation']
compileJava.options.release.set Integer.parseInt(rootProject.ext.javaVersion)
javadoc.options.encoding = 'UTF-8' javadoc.options.encoding = 'UTF-8'
javadoc.options.addStringOption('Xdoclint:none', '-quiet') javadoc.options.addStringOption('Xdoclint:none', '-quiet')
compileJava.options.release.set 16
repositories { repositories {
mavenLocal() mavenLocal()
mavenCentral() mavenCentral()
maven { url 'https://repo.william278.net/releases/' }
maven { url 'https://oss.sonatype.org/content/repositories/snapshots' }
maven { url 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' } maven { url 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' }
maven { url 'https://repo.papermc.io/repository/maven-public/' }
maven { url "https://repo.dmulloy2.net/repository/public/" }
maven { url 'https://repo.codemc.io/repository/maven-public/' } maven { url 'https://repo.codemc.io/repository/maven-public/' }
maven { url 'https://repo.minebench.de/' } maven { url 'https://repo.minebench.de/' }
maven { url 'https://repo.alessiodp.com/releases/' } maven { url 'https://repo.alessiodp.com/releases/' }
maven { url 'https://jitpack.io' } maven { url 'https://jitpack.io' }
maven { url 'https://mvn-repo.arim.space/lesser-gpl3/' } maven { url 'https://mvn-repo.arim.space/lesser-gpl3/' }
maven { url 'https://libraries.minecraft.net/' } maven { url 'https://libraries.minecraft.net/' }
maven { url 'https://repo.william278.net/releases/' }
} }
dependencies { dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.11.3'
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.0' testImplementation 'org.junit.jupiter:junit-jupiter-params:5.11.3'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.0' testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.11.3'
} }
test { test {
@@ -64,12 +102,20 @@ allprojects {
} }
processResources { processResources {
filter ReplaceTokens as Class, beginToken: '${', endToken: '}', def tokenMap = rootProject.ext.properties
tokens: rootProject.ext.properties tokenMap.merge("grgit",'',(s, s2) -> s)
filesMatching(['**/*.json', '**/*.yml']) {
filter ReplaceTokens as Class, beginToken: '${', endToken: '}',
tokens: tokenMap
}
} }
} }
subprojects { subprojects {
if (['fabric'].contains(project.name)) {
apply plugin: 'fabric-loom'
}
version rootProject.version version rootProject.version
archivesBaseName = "${rootProject.name}-${project.name.capitalize()}" archivesBaseName = "${rootProject.name}-${project.name.capitalize()}"
@@ -77,87 +123,99 @@ subprojects {
from '../LICENSE' from '../LICENSE'
} }
if (['bukkit', 'plugin'].contains(project.name)) { shadowJar {
shadowJar { destinationDirectory.set(file("$rootDir/target"))
destinationDirectory.set(file("$rootDir/target")) archiveClassifier.set('')
archiveClassifier.set('') }
// Append the compatible Minecraft version to the version
if (['bukkit', 'paper', 'fabric'].contains(project.name)) {
version += "+mc.${minecraft_version}"
}
// API publishing
if (['common', 'bukkit', 'fabric'].contains(project.name)) {
java {
withSourcesJar()
withJavadocJar()
} }
sourcesJar {
destinationDirectory.set(file("$rootDir/target"))
}
javadocJar {
destinationDirectory.set(file("$rootDir/target"))
}
shadowJar.dependsOn(sourcesJar, javadocJar)
// API publishing publishing {
if ('bukkit'.contains(project.name)) { if (['common'].contains(project.name)) {
java {
withSourcesJar()
withJavadocJar()
}
sourcesJar {
destinationDirectory.set(file("$rootDir/target"))
}
javadocJar {
destinationDirectory.set(file("$rootDir/target"))
}
shadowJar.dependsOn(sourcesJar, javadocJar)
publishing {
repositories {
if (System.getenv("RELEASES_MAVEN_USERNAME") != null) {
maven {
name = "william278-releases"
url = "https://repo.william278.net/releases"
credentials {
username = System.getenv("RELEASES_MAVEN_USERNAME")
password = System.getenv("RELEASES_MAVEN_PASSWORD")
}
authentication {
basic(BasicAuthentication)
}
}
}
if (System.getenv("SNAPSHOTS_MAVEN_USERNAME") != null) {
maven {
name = "william278-snapshots"
url = "https://repo.william278.net/snapshots"
credentials {
username = System.getenv("SNAPSHOTS_MAVEN_USERNAME")
password = System.getenv("SNAPSHOTS_MAVEN_PASSWORD")
}
authentication {
basic(BasicAuthentication)
}
}
}
}
publications { publications {
mavenJava(MavenPublication) { mavenJavaCommon(MavenPublication) {
groupId = 'net.william278' groupId = 'net.william278.husksync'
artifactId = 'husksync' artifactId = 'husksync-common'
version = "$rootProject.version" version = "$rootProject.version"
artifact shadowJar artifact shadowJar
artifact javadocJar
artifact sourcesJar artifact sourcesJar
artifact javadocJar
}
}
}
if (['bukkit'].contains(project.name)) {
publications {
mavenJavaBukkit(MavenPublication) {
groupId = 'net.william278.husksync'
artifactId = 'husksync-bukkit'
version = "$rootProject.version+${minecraft_version}"
artifact shadowJar
artifact sourcesJar
artifact javadocJar
}
}
}
if (['fabric'].contains(project.name)) {
publications {
mavenJavaFabric(MavenPublication) {
groupId = 'net.william278.husksync'
artifactId = 'husksync-fabric'
version = "$rootProject.version+${minecraft_version}"
artifact remapJar
artifact sourcesJar
artifact javadocJar
} }
} }
} }
} }
jar.dependsOn(shadowJar)
clean.delete "$rootDir/target"
} }
jar.dependsOn(shadowJar)
clean.delete "$rootDir/target"
} }
logger.lifecycle("Building HuskSync ${version} by William278") logger.lifecycle("Building HuskSync ${version} by William278 for Minecraft ${minecraft_version}")
@SuppressWarnings('GrMethodMayBeStatic') @SuppressWarnings('GrMethodMayBeStatic')
def versionMetadata() { def versionMetadata() {
// Get if there is a tag for this commit // If the force-hide-version-meta environment variable is set, return ''
if (System.getProperty('force-hide-version-meta') != null) {
return ''
}
// Require grgit
if (grgit == null) {
return '-unknown'
}
// If unclean, return the last commit hash with -indev
if (!grgit.status().clean) {
return '-' + grgit.head().abbreviatedId + '-indev'
}
// Otherwise if this matches a tag, return nothing
def tag = grgit.tag.list().find { it.commit.id == grgit.head().id } def tag = grgit.tag.list().find { it.commit.id == grgit.head().id }
if (tag != null) { if (tag != null) {
return '' return ''
} }
return '-' + grgit.head().abbreviatedId
// Otherwise, get the last commit hash and if it's a clean head }
if (grgit == null) {
return '-' + System.getenv("GITHUB_RUN_NUMBER") ? 'build.' + System.getenv("GITHUB_RUN_NUMBER") : 'unknown'
}
return '-' + grgit.head().abbreviatedId + (grgit.status().clean ? '' : '-indev')
}

View File

@@ -1,60 +1,59 @@
dependencies { dependencies {
implementation project(path: ':common') implementation project(path: ':common')
implementation 'org.bstats:bstats-bukkit:3.0.2' implementation 'net.william278.uniform:uniform-bukkit:1.2.3'
implementation 'net.william278:mpdbdataconverter:1.0.1' implementation 'net.william278:mpdbdataconverter:1.0.1'
implementation 'net.william278:hsldataconverter:1.0' implementation 'net.william278:hsldataconverter:1.0'
implementation 'net.william278:mapdataapi:1.0.3' implementation 'net.william278:mapdataapi:2.0'
implementation 'net.william278:andjam:1.0.2' implementation 'org.bstats:bstats-bukkit:3.1.0'
implementation 'me.lucko:commodore:2.2' implementation 'net.kyori:adventure-platform-bukkit:4.3.4'
implementation 'net.kyori:adventure-platform-bukkit:4.3.0' implementation 'dev.triumphteam:triumph-gui:3.1.10'
implementation 'dev.triumphteam:triumph-gui:3.1.5' implementation 'space.arim.morepaperlib:morepaperlib:0.4.4'
implementation 'space.arim.morepaperlib:morepaperlib:0.4.3' implementation 'de.tr7zw:item-nbt-api:2.14.1-SNAPSHOT'
implementation 'de.tr7zw:item-nbt-api:2.12.0'
compileOnly 'org.spigotmc:spigot-api:1.16.5-R0.1-SNAPSHOT' compileOnly "org.spigotmc:spigot-api:${bukkit_spigot_api}"
compileOnly 'commons-io:commons-io:2.13.0' compileOnly 'com.github.retrooper.packetevents:spigot:2.3.0'
compileOnly 'org.json:json:20230618' compileOnly 'com.comphenix.protocol:ProtocolLib:5.3.0'
compileOnly 'de.themoep:minedown-adventure:1.7.2-SNAPSHOT' compileOnly 'org.projectlombok:lombok:1.18.36'
compileOnly 'dev.dejvokep:boosted-yaml:1.3.1' compileOnly 'commons-io:commons-io:2.18.0'
compileOnly 'com.zaxxer:HikariCP:5.0.1' compileOnly 'org.json:json:20240303'
compileOnly 'net.william278:minedown:1.8.2'
compileOnly 'de.exlll:configlib-yaml:4.5.0'
compileOnly 'com.zaxxer:HikariCP:6.2.1'
compileOnly 'net.william278:DesertWell:2.0.4' compileOnly 'net.william278:DesertWell:2.0.4'
compileOnly 'net.william278:annotaml:2.0.7'
compileOnly 'net.william278:AdvancementAPI:97a9583413' compileOnly 'net.william278:AdvancementAPI:97a9583413'
compileOnly "redis.clients:jedis:$jedis_version" compileOnly "redis.clients:jedis:$jedis_version"
annotationProcessor 'org.projectlombok:lombok:1.18.36'
} }
shadowJar { shadowJar {
dependencies { dependencies {
exclude(dependency('com.mojang:brigadier')) exclude(dependency('com.mojang:brigadier'))
} }
relocate 'org.apache.commons.io', 'net.william278.husksync.libraries.commons.io' relocate 'org.apache.commons.io', 'net.william278.husksync.libraries.commons.io'
relocate 'org.apache.commons.text', 'net.william278.husksync.libraries.commons.text' relocate 'org.apache.commons.text', 'net.william278.husksync.libraries.commons.text'
relocate 'org.apache.commons.lang3', 'net.william278.husksync.libraries.commons.lang3' relocate 'org.apache.commons.lang3', 'net.william278.husksync.libraries.commons.lang3'
relocate 'com.google.gson', 'net.william278.husksync.libraries.gson' relocate 'com.google.gson', 'net.william278.husksync.libraries.gson'
relocate 'org.json', 'net.william278.husksync.libraries.json' relocate 'com.fatboyindustrial', 'net.william278.husksync.libraries'
relocate 'com.fatboyindustrial', 'net.william278.husktowns.libraries'
relocate 'de.themoep', 'net.william278.husksync.libraries' relocate 'de.themoep', 'net.william278.husksync.libraries'
relocate 'net.kyori', 'net.william278.husksync.libraries'
relocate 'org.jetbrains', 'net.william278.husksync.libraries' relocate 'org.jetbrains', 'net.william278.husksync.libraries'
relocate 'org.intellij', 'net.william278.husksync.libraries' relocate 'org.intellij', 'net.william278.husksync.libraries'
relocate 'com.zaxxer', 'net.william278.husksync.libraries' relocate 'com.zaxxer', 'net.william278.husksync.libraries'
relocate 'dev.dejvokep', 'net.william278.husksync.libraries' relocate 'de.exlll', 'net.william278.husksync.libraries'
relocate 'net.william278.uniform', 'net.william278.husksync.libraries.uniform'
relocate 'net.william278.desertwell', 'net.william278.husksync.libraries.desertwell' relocate 'net.william278.desertwell', 'net.william278.husksync.libraries.desertwell'
relocate 'net.william278.paginedown', 'net.william278.husksync.libraries.paginedown' relocate 'net.william278.paginedown', 'net.william278.husksync.libraries.paginedown'
relocate 'net.william278.mapdataapi', 'net.william278.husksync.libraries.mapdataapi' relocate 'net.william278.mapdataapi', 'net.william278.husksync.libraries.mapdataapi'
relocate 'net.william278.andjam', 'net.william278.husksync.libraries.andjam'
relocate 'net.querz', 'net.william278.husksync.libraries.nbtparser'
relocate 'net.roxeez', 'net.william278.husksync.libraries'
relocate 'me.lucko.commodore', 'net.william278.husksync.libraries.commodore'
relocate 'net.byteflux.libby', 'net.william278.husksync.libraries.libby'
relocate 'org.bstats', 'net.william278.husksync.libraries.bstats'
relocate 'dev.triumphteam.gui', 'net.william278.husksync.libraries.triumphgui'
relocate 'net.william278.mpdbconverter', 'net.william278.husksync.libraries.mpdbconverter' relocate 'net.william278.mpdbconverter', 'net.william278.husksync.libraries.mpdbconverter'
relocate 'net.william278.hslmigrator', 'net.william278.husksync.libraries.hslconverter' relocate 'net.william278.hslmigrator', 'net.william278.husksync.libraries.hslconverter'
relocate 'net.william278.annotaml', 'net.william278.husksync.libraries.annotaml' relocate 'org.json', 'net.william278.husksync.libraries.json'
relocate 'net.querz', 'net.william278.husksync.libraries.nbtparser'
relocate 'net.roxeez', 'net.william278.husksync.libraries'
relocate 'org.bstats', 'net.william278.husksync.libraries.bstats'
relocate 'dev.triumphteam.gui', 'net.william278.husksync.libraries.triumphgui'
relocate 'space.arim.morepaperlib', 'net.william278.husksync.libraries.paperlib' relocate 'space.arim.morepaperlib', 'net.william278.husksync.libraries.paperlib'
relocate 'de.tr7zw.changeme.nbtapi', 'net.william278.husksync.libraries.nbtapi' relocate 'de.tr7zw.changeme.nbtapi', 'net.william278.husksync.libraries.nbtapi'
minimize()
} }

View File

@@ -19,56 +19,69 @@
package net.william278.husksync; package net.william278.husksync;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.gson.Gson; import com.google.gson.Gson;
import de.tr7zw.changeme.nbtapi.utils.DataFixerUtil;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import net.kyori.adventure.platform.AudienceProvider;
import net.kyori.adventure.platform.bukkit.BukkitAudiences; import net.kyori.adventure.platform.bukkit.BukkitAudiences;
import net.william278.desertwell.util.Version; import net.william278.desertwell.util.Version;
import net.william278.husksync.adapter.DataAdapter; import net.william278.husksync.adapter.DataAdapter;
import net.william278.husksync.adapter.GsonAdapter; import net.william278.husksync.adapter.GsonAdapter;
import net.william278.husksync.adapter.SnappyGsonAdapter; import net.william278.husksync.adapter.SnappyGsonAdapter;
import net.william278.husksync.api.BukkitHuskSyncAPI; import net.william278.husksync.api.BukkitHuskSyncAPI;
import net.william278.husksync.command.BukkitCommand; import net.william278.husksync.command.PluginCommand;
import net.william278.husksync.config.Locales; import net.william278.husksync.config.Locales;
import net.william278.husksync.config.Server;
import net.william278.husksync.config.Settings; import net.william278.husksync.config.Settings;
import net.william278.husksync.data.BukkitSerializer; import net.william278.husksync.data.*;
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.database.Database;
import net.william278.husksync.database.MongoDbDatabase;
import net.william278.husksync.database.MySqlDatabase; import net.william278.husksync.database.MySqlDatabase;
import net.william278.husksync.database.PostgresDatabase;
import net.william278.husksync.event.BukkitEventDispatcher; import net.william278.husksync.event.BukkitEventDispatcher;
import net.william278.husksync.hook.PlanHook; import net.william278.husksync.hook.PlanHook;
import net.william278.husksync.listener.BukkitEventListener; import net.william278.husksync.listener.BukkitEventListener;
import net.william278.husksync.listener.EventListener;
import net.william278.husksync.migrator.LegacyMigrator; import net.william278.husksync.migrator.LegacyMigrator;
import net.william278.husksync.migrator.Migrator; import net.william278.husksync.migrator.Migrator;
import net.william278.husksync.migrator.MpdbMigrator; import net.william278.husksync.migrator.MpdbMigrator;
import net.william278.husksync.redis.RedisManager; import net.william278.husksync.redis.RedisManager;
import net.william278.husksync.sync.DataSyncer;
import net.william278.husksync.user.BukkitUser; import net.william278.husksync.user.BukkitUser;
import net.william278.husksync.user.ConsoleUser;
import net.william278.husksync.user.OnlineUser; import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.util.BukkitLegacyConverter; import net.william278.husksync.util.BukkitLegacyConverter;
import net.william278.husksync.util.BukkitMapPersister; import net.william278.husksync.util.BukkitMapPersister;
import net.william278.husksync.util.BukkitTask; import net.william278.husksync.util.BukkitTask;
import net.william278.husksync.util.LegacyConverter; import net.william278.husksync.util.LegacyConverter;
import net.william278.uniform.Uniform;
import net.william278.uniform.bukkit.BukkitUniform;
import org.bstats.bukkit.Metrics; import org.bstats.bukkit.Metrics;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.map.MapView; import org.bukkit.map.MapView;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import space.arim.morepaperlib.MorePaperLib; import space.arim.morepaperlib.MorePaperLib;
import space.arim.morepaperlib.commands.CommandRegistration;
import space.arim.morepaperlib.scheduling.AsynchronousScheduler; import space.arim.morepaperlib.scheduling.AsynchronousScheduler;
import space.arim.morepaperlib.scheduling.AttachedScheduler;
import space.arim.morepaperlib.scheduling.GracefulScheduling; import space.arim.morepaperlib.scheduling.GracefulScheduling;
import space.arim.morepaperlib.scheduling.RegionalScheduler; import space.arim.morepaperlib.scheduling.RegionalScheduler;
import java.nio.file.Path;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.Supplier, BukkitEventDispatcher, @Getter
BukkitMapPersister { @NoArgsConstructor
@SuppressWarnings("unchecked")
public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.Supplier,
BukkitEventDispatcher, BukkitMapPersister {
/** /**
* Metrics ID for <a href="https://bstats.org/plugin/bukkit/HuskSync%20-%20Bukkit/13140">HuskSync on Bukkit</a>. * Metrics ID for <a href="https://bstats.org/plugin/bukkit/HuskSync%20-%20Bukkit/13140">HuskSync on Bukkit</a>.
@@ -76,40 +89,66 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
private static final int METRICS_ID = 13140; private static final int METRICS_ID = 13140;
private static final String PLATFORM_TYPE_ID = "bukkit"; private static final String PLATFORM_TYPE_ID = "bukkit";
private final TreeMap<Identifier, Serializer<? extends Data>> serializers = Maps.newTreeMap(
SerializerRegistry.DEPENDENCY_ORDER_COMPARATOR
);
private final Map<UUID, Map<Identifier, Data>> playerCustomDataStore = Maps.newConcurrentMap();
private final Map<Integer, MapView> mapViews = Maps.newConcurrentMap();
private final List<Migrator> availableMigrators = Lists.newArrayList();
private final Set<UUID> lockedPlayers = Sets.newConcurrentHashSet();
private boolean disabling;
private Gson gson;
private AudienceProvider audiences;
private MorePaperLib paperLib;
private Database database; private Database database;
private RedisManager redisManager; private RedisManager redisManager;
private EventListener eventListener; private BukkitEventListener eventListener;
private DataAdapter dataAdapter; private DataAdapter dataAdapter;
private Map<Identifier, Serializer<? extends Data>> serializers; private DataSyncer dataSyncer;
private Map<UUID, Map<Identifier, Data>> playerCustomDataStore;
private Settings settings;
private Locales locales;
private List<Migrator> availableMigrators;
private LegacyConverter legacyConverter; private LegacyConverter legacyConverter;
private Map<Integer, MapView> mapViews;
private BukkitAudiences audiences;
private MorePaperLib paperLib;
private AsynchronousScheduler asyncScheduler; private AsynchronousScheduler asyncScheduler;
private RegionalScheduler regionalScheduler; private RegionalScheduler regionalScheduler;
private Gson gson; @Setter
private Settings settings;
@Setter
private Locales locales;
@Setter
@Getter(AccessLevel.NONE)
private Server serverName;
@Override
public void onLoad() {
// Initial plugin setup
this.disabling = false;
this.gson = createGson();
this.paperLib = new MorePaperLib(this);
// Load settings and locales
initialize("plugin config & locale files", (plugin) -> {
loadSettings();
loadLocales();
loadServer();
validateConfigFiles();
});
this.eventListener = createEventListener();
eventListener.onLoad();
}
@Override @Override
public void onEnable() { public void onEnable() {
// Initial plugin setup
this.gson = createGson();
this.audiences = BukkitAudiences.create(this); this.audiences = BukkitAudiences.create(this);
this.paperLib = new MorePaperLib(this);
this.availableMigrators = new ArrayList<>();
this.serializers = new LinkedHashMap<>();
this.playerCustomDataStore = new ConcurrentHashMap<>();
this.mapViews = new ConcurrentHashMap<>();
// Load settings and locales // Check compatibility
initialize("plugin config & locale files", (plugin) -> this.loadConfigs()); checkCompatibility();
// Register commands
initialize("commands", (plugin) -> getUniform().register(PluginCommand.Type.create(this)));
// Prepare data adapter // Prepare data adapter
initialize("data adapter", (plugin) -> { initialize("data adapter", (plugin) -> {
if (settings.doCompressData()) { if (settings.getSynchronization().isCompressData()) {
dataAdapter = new SnappyGsonAdapter(this); dataAdapter = new SnappyGsonAdapter(this);
} else { } else {
dataAdapter = new GsonAdapter(this); dataAdapter = new GsonAdapter(this);
@@ -118,17 +157,20 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
// Prepare serializers // Prepare serializers
initialize("data serializers", (plugin) -> { initialize("data serializers", (plugin) -> {
registerSerializer(Identifier.PERSISTENT_DATA, new BukkitSerializer.PersistentData(this));
registerSerializer(Identifier.INVENTORY, new BukkitSerializer.Inventory(this)); registerSerializer(Identifier.INVENTORY, new BukkitSerializer.Inventory(this));
registerSerializer(Identifier.ENDER_CHEST, new BukkitSerializer.EnderChest(this)); registerSerializer(Identifier.ENDER_CHEST, new BukkitSerializer.EnderChest(this));
registerSerializer(Identifier.ADVANCEMENTS, new BukkitSerializer.Advancements(this)); registerSerializer(Identifier.ADVANCEMENTS, new BukkitSerializer.Advancements(this));
registerSerializer(Identifier.LOCATION, new BukkitSerializer.Location(this)); registerSerializer(Identifier.STATISTICS, new Serializer.Json<>(this, BukkitData.Statistics.class));
registerSerializer(Identifier.HEALTH, new BukkitSerializer.Health(this));
registerSerializer(Identifier.HUNGER, new BukkitSerializer.Hunger(this));
registerSerializer(Identifier.GAME_MODE, new BukkitSerializer.GameMode(this));
registerSerializer(Identifier.POTION_EFFECTS, new BukkitSerializer.PotionEffects(this)); registerSerializer(Identifier.POTION_EFFECTS, new BukkitSerializer.PotionEffects(this));
registerSerializer(Identifier.STATISTICS, new BukkitSerializer.Statistics(this)); registerSerializer(Identifier.GAME_MODE, new Serializer.Json<>(this, BukkitData.GameMode.class));
registerSerializer(Identifier.EXPERIENCE, new BukkitSerializer.Experience(this)); registerSerializer(Identifier.FLIGHT_STATUS, new Serializer.Json<>(this, BukkitData.FlightStatus.class));
registerSerializer(Identifier.PERSISTENT_DATA, new BukkitSerializer.PersistentData(this)); registerSerializer(Identifier.ATTRIBUTES, new Serializer.Json<>(this, BukkitData.Attributes.class));
registerSerializer(Identifier.HEALTH, new Serializer.Json<>(this, BukkitData.Health.class));
registerSerializer(Identifier.HUNGER, new Serializer.Json<>(this, BukkitData.Hunger.class));
registerSerializer(Identifier.EXPERIENCE, new Serializer.Json<>(this, BukkitData.Experience.class));
registerSerializer(Identifier.LOCATION, new Serializer.Json<>(this, BukkitData.Location.class));
validateDependencies();
}); });
// Setup available migrators // Setup available migrators
@@ -141,8 +183,12 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
}); });
// Initialize the database // Initialize the database
initialize(getSettings().getDatabaseType().getDisplayName() + " database connection", (plugin) -> { initialize(getSettings().getDatabase().getType().getDisplayName() + " database connection", (plugin) -> {
this.database = new MySqlDatabase(this); this.database = switch (settings.getDatabase().getType()) {
case MYSQL, MARIADB -> new MySqlDatabase(this);
case POSTGRES -> new PostgresDatabase(this);
case MONGO -> new MongoDbDatabase(this);
};
this.database.initialize(); this.database.initialize();
}); });
@@ -152,15 +198,18 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
this.redisManager.initialize(); this.redisManager.initialize();
}); });
// Register events // Prepare data syncer
initialize("events", (plugin) -> this.eventListener = new BukkitEventListener(this)); initialize("data syncer", (plugin) -> {
dataSyncer = getSettings().getSynchronization().getMode().create(this);
dataSyncer.initialize();
});
// Register commands // Register events
initialize("commands", (plugin) -> BukkitCommand.Type.registerCommands(this)); initialize("events", (plugin) -> eventListener.onEnable());
// Register plugin hooks // Register plugin hooks
initialize("hooks", (plugin) -> { initialize("hooks", (plugin) -> {
if (isDependencyLoaded("Plan") && getSettings().usePlanHook()) { if (isDependencyLoaded("Plan") && getSettings().isEnablePlanHook()) {
new PlanHook(this).hookIntoPlan(); new PlanHook(this).hookIntoPlan();
} }
}); });
@@ -176,6 +225,12 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
@Override @Override
public void onDisable() { public void onDisable() {
// Handle shutdown // Handle shutdown
this.disabling = true;
// Close the event listener / data syncer
if (this.dataSyncer != null) {
this.dataSyncer.terminate();
}
if (this.eventListener != null) { if (this.eventListener != null) {
this.eventListener.handlePluginDisable(); this.eventListener.handlePluginDisable();
} }
@@ -188,10 +243,15 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
log(Level.INFO, "Successfully disabled HuskSync v" + getPluginVersion()); log(Level.INFO, "Successfully disabled HuskSync v" + getPluginVersion());
} }
@NotNull
protected BukkitEventListener createEventListener() {
return new BukkitEventListener(this);
}
@Override @Override
@NotNull @NotNull
public Set<OnlineUser> getOnlineUsers() { public Set<OnlineUser> getOnlineUsers() {
return Bukkit.getOnlinePlayers().stream() return getServer().getOnlinePlayers().stream()
.map(player -> BukkitUser.adapt(player, this)) .map(player -> BukkitUser.adapt(player, this))
.collect(Collectors.toSet()); .collect(Collectors.toSet());
} }
@@ -199,7 +259,7 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
@Override @Override
@NotNull @NotNull
public Optional<OnlineUser> getOnlineUser(@NotNull UUID uuid) { public Optional<OnlineUser> getOnlineUser(@NotNull UUID uuid) {
final Player player = Bukkit.getPlayer(uuid); final Player player = getServer().getPlayer(uuid);
if (player == null) { if (player == null) {
return Optional.empty(); return Optional.empty();
} }
@@ -207,71 +267,36 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
} }
@Override @Override
@NotNull public void setDataSyncer(@NotNull DataSyncer dataSyncer) {
public Database getDatabase() { log(Level.INFO, String.format("Switching data syncer to %s", dataSyncer.getClass().getSimpleName()));
return database; this.dataSyncer = dataSyncer;
} }
@Override @Override
@NotNull @NotNull
public RedisManager getRedisManager() { public Uniform getUniform() {
return redisManager; return BukkitUniform.getInstance(this);
}
@NotNull
@Override
public DataAdapter getDataAdapter() {
return dataAdapter;
}
@NotNull
@Override
public Map<Identifier, Serializer<? extends Data>> getSerializers() {
return serializers;
}
@NotNull
@Override
public List<Migrator> getAvailableMigrators() {
return availableMigrators;
} }
@NotNull @NotNull
@Override @Override
public Map<Identifier, Data> getPlayerCustomDataStore(@NotNull OnlineUser user) { public Map<Identifier, Data> getPlayerCustomDataStore(@NotNull OnlineUser user) {
if (playerCustomDataStore.containsKey(user.getUuid())) { return playerCustomDataStore.compute(
return playerCustomDataStore.get(user.getUuid()); user.getUuid(),
} (uuid, data) -> data == null ? Maps.newHashMap() : data
final Map<Identifier, Data> data = new HashMap<>(); );
playerCustomDataStore.put(user.getUuid(), data);
return data;
} }
@Override @Override
@NotNull @NotNull
public Settings getSettings() { public String getServerName() {
return settings; return serverName == null ? "server" : serverName.getName();
}
@Override
public void setSettings(@NotNull Settings settings) {
this.settings = settings;
}
@Override
@NotNull
public Locales getLocales() {
return locales;
}
@Override
public void setLocales(@NotNull Locales locales) {
this.locales = locales;
} }
@Override @Override
public boolean isDependencyLoaded(@NotNull String name) { public boolean isDependencyLoaded(@NotNull String name) {
return Bukkit.getPluginManager().getPlugin(name) != null; final Plugin plugin = getServer().getPluginManager().getPlugin(name);
return plugin != null;
} }
// Register bStats metrics // Register bStats metrics
@@ -283,7 +308,7 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
try { try {
new Metrics(this, metricsId); new Metrics(this, metricsId);
} catch (Throwable e) { } catch (Throwable e) {
log(Level.WARNING, "Failed to register bStats metrics (" + e.getMessage() + ")"); log(Level.WARNING, "Failed to register bStats metrics (%s)".formatted(e.getMessage()));
} }
} }
@@ -296,12 +321,6 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
} }
} }
@NotNull
@Override
public ConsoleUser getConsole() {
return new ConsoleUser(audiences.console());
}
@NotNull @NotNull
@Override @Override
public Version getPluginVersion() { public Version getPluginVersion() {
@@ -311,7 +330,23 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
@NotNull @NotNull
@Override @Override
public Version getMinecraftVersion() { public Version getMinecraftVersion() {
return Version.fromString(Bukkit.getBukkitVersion()); return Version.fromString(getServer().getBukkitVersion());
}
public int getDataVersion(@NotNull Version mcVersion) {
return switch (mcVersion.toStringWithoutMetadata()) {
case "1.16", "1.16.1", "1.16.2", "1.16.3", "1.16.4", "1.16.5" -> DataFixerUtil.VERSION1_16_5;
case "1.17", "1.17.1" -> DataFixerUtil.VERSION1_17_1;
case "1.18", "1.18.1", "1.18.2" -> DataFixerUtil.VERSION1_18_2;
case "1.19", "1.19.1", "1.19.2" -> DataFixerUtil.VERSION1_19_2;
case "1.20", "1.20.1", "1.20.2" -> DataFixerUtil.VERSION1_20_2;
case "1.20.3", "1.20.4" -> DataFixerUtil.VERSION1_20_4;
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*/;
default -> DataFixerUtil.getCurrentVersion();
};
} }
@NotNull @NotNull
@@ -320,28 +355,17 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
return PLATFORM_TYPE_ID; return PLATFORM_TYPE_ID;
} }
@Override
@NotNull
public String getServerVersion() {
return String.format("%s/%s", getServer().getName(), getServer().getVersion());
}
@Override @Override
public Optional<LegacyConverter> getLegacyConverter() { public Optional<LegacyConverter> getLegacyConverter() {
return Optional.of(legacyConverter); return Optional.of(legacyConverter);
} }
@NotNull
@Override
public Set<UUID> getLockedPlayers() {
return this.eventListener.getLockedPlayers();
}
@NotNull
@Override
public Gson getGson() {
return gson;
}
@NotNull
public Map<Integer, MapView> getMapViews() {
return mapViews;
}
@NotNull @NotNull
public GracefulScheduling getScheduler() { public GracefulScheduling getScheduler() {
return paperLib.scheduling(); return paperLib.scheduling();
@@ -354,24 +378,25 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
} }
@NotNull @NotNull
public RegionalScheduler getRegionalScheduler() { public RegionalScheduler getSyncScheduler() {
return regionalScheduler == null return regionalScheduler == null
? regionalScheduler = getScheduler().globalRegionalScheduler() : regionalScheduler; ? regionalScheduler = getScheduler().globalRegionalScheduler() : regionalScheduler;
} }
@NotNull @NotNull
public BukkitAudiences getAudiences() { public AttachedScheduler getUserSyncScheduler(@NotNull UserDataHolder user) {
return audiences; return getScheduler().entitySpecificScheduler(((BukkitUser) user).getPlayer());
}
@NotNull
public CommandRegistration getCommandRegistrar() {
return paperLib.commandRegistration();
} }
@Override @Override
@NotNull @NotNull
public HuskSync getPlugin() { public Path getConfigDirectory() {
return getDataFolder().toPath();
}
@Override
@NotNull
public BukkitHuskSync getPlugin() {
return this; return this;
} }

View File

@@ -28,6 +28,7 @@ import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User; import net.william278.husksync.user.User;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.java.JavaPlugin;
import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
@@ -43,9 +44,6 @@ import java.util.function.Consumer;
@SuppressWarnings("unused") @SuppressWarnings("unused")
public class BukkitHuskSyncAPI extends HuskSyncAPI { public class BukkitHuskSyncAPI extends HuskSyncAPI {
// Instance of the plugin
private static BukkitHuskSyncAPI instance;
/** /**
* <b>(Internal use only)</b> - Constructor, instantiating the API. * <b>(Internal use only)</b> - Constructor, instantiating the API.
*/ */
@@ -55,17 +53,21 @@ public class BukkitHuskSyncAPI extends HuskSyncAPI {
} }
/** /**
* Entrypoint to the HuskSync API - returns an instance of the API * Entrypoint to the HuskSync API on the bukkit platform - returns an instance of the API
* *
* @return instance of the HuskSync API * @return instance of the HuskSync API
* @since 3.0 * @since 3.0
*/ */
@NotNull @NotNull
public static BukkitHuskSyncAPI getInstance() { public static BukkitHuskSyncAPI getInstance() {
if (!JavaPlugin.getProvidingPlugin(BukkitHuskSyncAPI.class).getName().equals("HuskSync")) {
throw new NotRegisteredException("This is likely because you have shaded HuskSync into your plugin JAR " +
"and need to fix your maven/gradle/build script so that it *compiles against* HuskSync instead.");
}
if (instance == null) { if (instance == null) {
throw new NotRegisteredException(); throw new NotRegisteredException();
} }
return instance; return (BukkitHuskSyncAPI) instance;
} }
/** /**
@@ -79,14 +81,6 @@ public class BukkitHuskSyncAPI extends HuskSyncAPI {
instance = new BukkitHuskSyncAPI(plugin); instance = new BukkitHuskSyncAPI(plugin);
} }
/**
* <b>(Internal use only)</b> - Unregister the API for this platform.
*/
@ApiStatus.Internal
public static void unregister() {
instance = null;
}
/** /**
* Returns a {@link OnlineUser} instance for the given bukkit {@link Player}. * Returns a {@link OnlineUser} instance for the given bukkit {@link Player}.
* *

View File

@@ -1,60 +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 me.lucko.commodore.CommodoreProvider;
import me.lucko.commodore.file.CommodoreFileReader;
import net.william278.husksync.BukkitHuskSync;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.io.InputStream;
import java.util.logging.Level;
public class BrigadierUtil {
/**
* Uses commodore to register command completions.
*
* @param plugin instance of the registering Bukkit plugin
* @param bukkitCommand the Bukkit PluginCommand to register completions for
* @param command the {@link Command} to register completions for
*/
protected static void registerCommodore(@NotNull BukkitHuskSync plugin,
@NotNull org.bukkit.command.Command bukkitCommand,
@NotNull Command command) {
final InputStream commodoreFile = plugin.getResource(
"commodore/" + bukkitCommand.getName() + ".commodore"
);
if (commodoreFile == null) {
return;
}
try {
CommodoreProvider.getCommodore(plugin).register(bukkitCommand,
CommodoreFileReader.INSTANCE.parse(commodoreFile),
player -> player.hasPermission(command.getPermission()));
} catch (IOException e) {
plugin.log(Level.SEVERE, String.format(
"Failed to read command commodore completions for %s", bukkitCommand.getName()), e
);
}
}
}

View File

@@ -1,164 +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 me.lucko.commodore.CommodoreProvider;
import net.william278.husksync.BukkitHuskSync;
import net.william278.husksync.user.BukkitUser;
import net.william278.husksync.user.CommandUser;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.permissions.Permission;
import org.bukkit.permissions.PermissionDefault;
import org.bukkit.plugin.PluginManager;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.function.Function;
public class BukkitCommand extends org.bukkit.command.Command {
private final BukkitHuskSync plugin;
private final Command command;
public BukkitCommand(@NotNull Command command, @NotNull BukkitHuskSync plugin) {
super(command.getName(), command.getDescription(), command.getUsage(), command.getAliases());
this.command = command;
this.plugin = plugin;
}
@Override
public boolean execute(@NotNull CommandSender sender, @NotNull String commandLabel, @NotNull String[] args) {
this.command.onExecuted(sender instanceof Player p ? BukkitUser.adapt(p, plugin) : plugin.getConsole(), args);
return true;
}
@NotNull
@Override
public List<String> tabComplete(@NotNull CommandSender sender, @NotNull String alias,
@NotNull String[] args) throws IllegalArgumentException {
if (!(this.command instanceof TabProvider provider)) {
return List.of();
}
final CommandUser user = sender instanceof Player p ? BukkitUser.adapt(p, plugin) : plugin.getConsole();
if (getPermission() == null || user.hasPermission(getPermission())) {
return provider.getSuggestions(user, args);
}
return List.of();
}
public void register() {
// Register with bukkit
plugin.getCommandRegistrar().getServerCommandMap().register("husksync", this);
// Register permissions
BukkitCommand.addPermission(
plugin,
command.getPermission(),
command.getUsage(),
BukkitCommand.getPermissionDefault(command.isOperatorCommand())
);
final List<Permission> childNodes = command.getAdditionalPermissions()
.entrySet().stream()
.map((entry) -> BukkitCommand.addPermission(
plugin,
entry.getKey(),
"",
BukkitCommand.getPermissionDefault(entry.getValue()))
)
.filter(Objects::nonNull)
.toList();
if (!childNodes.isEmpty()) {
BukkitCommand.addPermission(
plugin,
command.getPermission("*"),
command.getUsage(),
PermissionDefault.FALSE,
childNodes.toArray(new Permission[0])
);
}
// Register commodore TAB completion
if (CommodoreProvider.isSupported() && plugin.getSettings().doBrigadierTabCompletion()) {
BrigadierUtil.registerCommodore(plugin, this, command);
}
}
@Nullable
protected static Permission addPermission(@NotNull BukkitHuskSync plugin, @NotNull String node,
@NotNull String description, @NotNull PermissionDefault permissionDefault,
@NotNull Permission... children) {
final Map<String, Boolean> childNodes = Arrays.stream(children)
.map(Permission::getName)
.collect(HashMap::new, (map, child) -> map.put(child, true), HashMap::putAll);
final PluginManager manager = plugin.getServer().getPluginManager();
if (manager.getPermission(node) != null) {
return null;
}
Permission permission;
if (description.isEmpty()) {
permission = new Permission(node, permissionDefault, childNodes);
} else {
permission = new Permission(node, description, permissionDefault, childNodes);
}
manager.addPermission(permission);
return permission;
}
@NotNull
protected static PermissionDefault getPermissionDefault(boolean isOperatorCommand) {
return isOperatorCommand ? PermissionDefault.OP : PermissionDefault.TRUE;
}
/**
* Commands available on the Bukkit HuskSync implementation
*/
public enum Type {
HUSKSYNC_COMMAND(HuskSyncCommand::new),
USERDATA_COMMAND(UserDataCommand::new),
INVENTORY_COMMAND(InventoryCommand::new),
ENDER_CHEST_COMMAND(EnderChestCommand::new);
public final Function<BukkitHuskSync, Command> commandSupplier;
Type(@NotNull Function<BukkitHuskSync, Command> supplier) {
this.commandSupplier = supplier;
}
@NotNull
public Command createCommand(@NotNull BukkitHuskSync plugin) {
return commandSupplier.apply(plugin);
}
public static void registerCommands(@NotNull BukkitHuskSync plugin) {
Arrays.stream(values())
.map((type) -> type.createCommand(plugin))
.forEach((command) -> new BukkitCommand(command, plugin).register());
}
}
}

View File

@@ -21,27 +21,35 @@ package net.william278.husksync.data;
import com.google.gson.reflect.TypeToken; import com.google.gson.reflect.TypeToken;
import de.tr7zw.changeme.nbtapi.NBT; import de.tr7zw.changeme.nbtapi.NBT;
import de.tr7zw.changeme.nbtapi.NBTCompound;
import de.tr7zw.changeme.nbtapi.NBTContainer; import de.tr7zw.changeme.nbtapi.NBTContainer;
import de.tr7zw.changeme.nbtapi.iface.ReadWriteNBT; import de.tr7zw.changeme.nbtapi.iface.ReadWriteNBT;
import de.tr7zw.changeme.nbtapi.iface.ReadWriteNBTCompoundList;
import de.tr7zw.changeme.nbtapi.utils.DataFixerUtil;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import net.william278.desertwell.util.Version;
import net.william278.husksync.BukkitHuskSync;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.Adaptable; import net.william278.husksync.adapter.Adaptable;
import net.william278.husksync.api.HuskSyncAPI; import net.william278.husksync.api.HuskSyncAPI;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List; import java.util.List;
import static net.william278.husksync.data.BukkitData.Items.Inventory.INVENTORY_SLOT_COUNT; import static net.william278.husksync.data.BukkitData.Items.Inventory.INVENTORY_SLOT_COUNT;
import static net.william278.husksync.data.Data.Items.Inventory.HELD_ITEM_SLOT_TAG;
import static net.william278.husksync.data.Data.Items.Inventory.ITEMS_TAG;
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class BukkitSerializer { public class BukkitSerializer {
protected final HuskSync plugin; protected final HuskSync plugin;
private BukkitSerializer(@NotNull HuskSync plugin) {
this.plugin = plugin;
}
@SuppressWarnings("unused") @SuppressWarnings("unused")
public BukkitSerializer(@NotNull HuskSyncAPI api) { public BukkitSerializer(@NotNull HuskSyncAPI api) {
this.plugin = api.getPlugin(); this.plugin = api.getPlugin();
@@ -53,25 +61,29 @@ public class BukkitSerializer {
return plugin; return plugin;
} }
public static class Inventory extends BukkitSerializer implements Serializer<BukkitData.Items.Inventory> { public static class Inventory extends BukkitSerializer implements Serializer<BukkitData.Items.Inventory>,
private static final String ITEMS_TAG = "items"; ItemDeserializer {
private static final String HELD_ITEM_SLOT_TAG = "held_item_slot";
public Inventory(@NotNull HuskSync plugin) { public Inventory(@NotNull HuskSync plugin) {
super(plugin); super(plugin);
} }
@Override @Override
public BukkitData.Items.Inventory deserialize(@NotNull String serialized) throws DeserializationException { public BukkitData.Items.Inventory deserialize(@NotNull String serialized, @NotNull Version dataMcVersion)
throws DeserializationException {
final ReadWriteNBT root = NBT.parseNBT(serialized); final ReadWriteNBT root = NBT.parseNBT(serialized);
final ItemStack[] items = root.getItemStackArray(ITEMS_TAG); final ReadWriteNBT items = root.hasTag(ITEMS_TAG) ? root.getCompound(ITEMS_TAG) : null;
final int heldItemSlot = root.getInteger(HELD_ITEM_SLOT_TAG);
return BukkitData.Items.Inventory.from( return BukkitData.Items.Inventory.from(
items == null ? new ItemStack[INVENTORY_SLOT_COUNT] : items, items != null ? getItems(items, dataMcVersion) : new ItemStack[INVENTORY_SLOT_COUNT],
heldItemSlot root.hasTag(HELD_ITEM_SLOT_TAG) ? root.getInteger(HELD_ITEM_SLOT_TAG) : 0
); );
} }
@Override
public BukkitData.Items.Inventory deserialize(@NotNull String serialized) {
return deserialize(serialized, plugin.getMinecraftVersion());
}
@NotNull @NotNull
@Override @Override
public String serialize(@NotNull BukkitData.Items.Inventory data) throws SerializationException { public String serialize(@NotNull BukkitData.Items.Inventory data) throws SerializationException {
@@ -83,18 +95,25 @@ public class BukkitSerializer {
} }
public static class EnderChest extends BukkitSerializer implements Serializer<BukkitData.Items.EnderChest> { public static class EnderChest extends BukkitSerializer implements Serializer<BukkitData.Items.EnderChest>,
ItemDeserializer {
public EnderChest(@NotNull HuskSync plugin) { public EnderChest(@NotNull HuskSync plugin) {
super(plugin); super(plugin);
} }
@Override @Override
public BukkitData.Items.EnderChest deserialize(@NotNull String serialized) throws DeserializationException { public BukkitData.Items.EnderChest deserialize(@NotNull String serialized, @NotNull Version dataMcVersion)
final ItemStack[] items = NBT.itemStackArrayFromNBT(NBT.parseNBT(serialized)); throws DeserializationException {
final ItemStack[] items = getItems(NBT.parseNBT(serialized), dataMcVersion);
return items == null ? BukkitData.Items.EnderChest.empty() : BukkitData.Items.EnderChest.adapt(items); return items == null ? BukkitData.Items.EnderChest.empty() : BukkitData.Items.EnderChest.adapt(items);
} }
@Override
public BukkitData.Items.EnderChest deserialize(@NotNull String serialized) {
return deserialize(serialized, plugin.getMinecraftVersion());
}
@NotNull @NotNull
@Override @Override
public String serialize(@NotNull BukkitData.Items.EnderChest data) throws SerializationException { public String serialize(@NotNull BukkitData.Items.EnderChest data) throws SerializationException {
@@ -102,6 +121,49 @@ public class BukkitSerializer {
} }
} }
// Utility interface for deserializing and upgrading item stacks from legacy versions
private interface ItemDeserializer {
@Nullable
default ItemStack[] getItems(@NotNull ReadWriteNBT tag, @NotNull Version mcVersion) {
if (mcVersion.compareTo(getPlugin().getMinecraftVersion()) < 0) {
return upgradeItemStacks((NBTCompound) tag, mcVersion);
}
return NBT.itemStackArrayFromNBT(tag);
}
@NotNull
private ItemStack @NotNull [] upgradeItemStacks(@NotNull NBTCompound itemsNbt, @NotNull Version mcVersion) {
final ReadWriteNBTCompoundList items = itemsNbt.getCompoundList("items");
final ItemStack[] itemStacks = new ItemStack[itemsNbt.getInteger("size")];
for (int i = 0; i < items.size(); i++) {
if (items.get(i) == null) {
itemStacks[i] = new ItemStack(Material.AIR);
continue;
}
try {
itemStacks[i] = NBT.itemStackFromNBT(upgradeItemData(items.get(i), mcVersion));
} catch (Throwable e) {
itemStacks[i] = new ItemStack(Material.AIR);
}
}
return itemStacks;
}
@NotNull
private ReadWriteNBT upgradeItemData(@NotNull ReadWriteNBT tag, @NotNull Version mcVersion)
throws NoSuchFieldException, IllegalAccessException {
return DataFixerUtil.fixUpItemData(
tag,
getPlugin().getDataVersion(mcVersion),
DataFixerUtil.getCurrentVersion()
);
}
@NotNull
HuskSync getPlugin();
}
public static class PotionEffects extends BukkitSerializer implements Serializer<BukkitData.PotionEffects> { public static class PotionEffects extends BukkitSerializer implements Serializer<BukkitData.PotionEffects> {
private static final TypeToken<List<Data.PotionEffects.Effect>> TYPE = new TypeToken<>() { private static final TypeToken<List<Data.PotionEffects.Effect>> TYPE = new TypeToken<>() {
@@ -149,46 +211,6 @@ public class BukkitSerializer {
} }
} }
public static class Location extends BukkitSerializer implements Serializer<BukkitData.Location> {
public Location(@NotNull HuskSync plugin) {
super(plugin);
}
@Override
public BukkitData.Location deserialize(@NotNull String serialized) throws DeserializationException {
return plugin.getDataAdapter().fromJson(serialized, BukkitData.Location.class);
}
@NotNull
@Override
public String serialize(@NotNull BukkitData.Location element) throws SerializationException {
return plugin.getDataAdapter().toJson(element);
}
}
public static class Statistics extends BukkitSerializer implements Serializer<BukkitData.Statistics> {
public Statistics(@NotNull HuskSync plugin) {
super(plugin);
}
@Override
public BukkitData.Statistics deserialize(@NotNull String serialized) throws DeserializationException {
return BukkitData.Statistics.from(plugin.getGson().fromJson(
serialized,
BukkitData.Statistics.StatisticsMap.class
));
}
@NotNull
@Override
public String serialize(@NotNull BukkitData.Statistics element) throws SerializationException {
return plugin.getGson().toJson(element.getStatisticsSet());
}
}
public static class PersistentData extends BukkitSerializer implements Serializer<BukkitData.PersistentData> { public static class PersistentData extends BukkitSerializer implements Serializer<BukkitData.PersistentData> {
public PersistentData(@NotNull HuskSync plugin) { public PersistentData(@NotNull HuskSync plugin) {
@@ -197,7 +219,7 @@ public class BukkitSerializer {
@Override @Override
public BukkitData.PersistentData deserialize(@NotNull String serialized) throws DeserializationException { public BukkitData.PersistentData deserialize(@NotNull String serialized) throws DeserializationException {
return BukkitData.PersistentData.from(new NBTContainer(serialized)); return BukkitData.PersistentData.from((NBTContainer) NBT.parseNBT(serialized));
} }
@NotNull @NotNull
@@ -208,56 +230,19 @@ public class BukkitSerializer {
} }
public static class Health extends Json<BukkitData.Health> implements Serializer<BukkitData.Health> { /**
* @deprecated Use {@link Serializer.Json} in the common module instead
*/
@Deprecated(since = "2.6")
public class Json<T extends Data & Adaptable> extends Serializer.Json<T> {
public Health(@NotNull HuskSync plugin) { public Json(@NotNull HuskSync plugin, @NotNull Class<T> type) {
super(plugin, BukkitData.Health.class); super(plugin, type);
}
}
public static class Hunger extends Json<BukkitData.Hunger> implements Serializer<BukkitData.Hunger> {
public Hunger(@NotNull HuskSync plugin) {
super(plugin, BukkitData.Hunger.class);
}
}
public static class Experience extends Json<BukkitData.Experience> implements Serializer<BukkitData.Experience> {
public Experience(@NotNull HuskSync plugin) {
super(plugin, BukkitData.Experience.class);
}
}
public static class GameMode extends Json<BukkitData.GameMode> implements Serializer<BukkitData.GameMode> {
public GameMode(@NotNull HuskSync plugin) {
super(plugin, BukkitData.GameMode.class);
}
}
public static abstract class Json<T extends Data & Adaptable> extends BukkitSerializer implements Serializer<T> {
private final Class<T> type;
protected Json(@NotNull HuskSync plugin, Class<T> type) {
super(plugin);
this.type = type;
}
@Override
public T deserialize(@NotNull String serialized) throws DeserializationException {
return plugin.getDataAdapter().fromJson(serialized, type);
} }
@NotNull @NotNull
@Override public BukkitHuskSync getPlugin() {
public String serialize(@NotNull T element) throws SerializationException { return (BukkitHuskSync) plugin;
return plugin.getDataAdapter().toJson(element);
} }
} }

View File

@@ -25,7 +25,6 @@ import org.bukkit.entity.Player;
import org.bukkit.inventory.PlayerInventory; import org.bukkit.inventory.PlayerInventory;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
public interface BukkitUserDataHolder extends UserDataHolder { public interface BukkitUserDataHolder extends UserDataHolder {
@@ -42,8 +41,10 @@ public interface BukkitUserDataHolder extends UserDataHolder {
case "statistics" -> getStatistics(); case "statistics" -> getStatistics();
case "health" -> getHealth(); case "health" -> getHealth();
case "hunger" -> getHunger(); case "hunger" -> getHunger();
case "attributes" -> getAttributes();
case "experience" -> getExperience(); case "experience" -> getExperience();
case "game_mode" -> getGameMode(); case "game_mode" -> getGameMode();
case "flight_status" -> getFlightStatus();
case "persistent_data" -> getPersistentData(); case "persistent_data" -> getPersistentData();
default -> throw new IllegalStateException(String.format("Unexpected data type: %s", id)); default -> throw new IllegalStateException(String.format("Unexpected data type: %s", id));
}; };
@@ -62,12 +63,13 @@ public interface BukkitUserDataHolder extends UserDataHolder {
@NotNull @NotNull
@Override @Override
default Optional<Data.Items.Inventory> getInventory() { default Optional<Data.Items.Inventory> getInventory() {
if ((isDead() && !getPlugin().getSettings().doSynchronizeDeadPlayersChangingServer())) { if ((isDead() && !getPlugin().getSettings().getSynchronization().getSaveOnDeath()
.isSyncDeadPlayersChangingServer())) {
return Optional.of(BukkitData.Items.Inventory.empty()); return Optional.of(BukkitData.Items.Inventory.empty());
} }
final PlayerInventory inventory = getBukkitPlayer().getInventory(); final PlayerInventory inventory = getPlayer().getInventory();
return Optional.of(BukkitData.Items.Inventory.from( return Optional.of(BukkitData.Items.Inventory.from(
getMapPersister().persistLockedMaps(inventory.getContents(), getBukkitPlayer()), getMapPersister().persistLockedMaps(inventory.getContents(), getPlayer()),
inventory.getHeldItemSlot() inventory.getHeldItemSlot()
)); ));
} }
@@ -76,71 +78,89 @@ public interface BukkitUserDataHolder extends UserDataHolder {
@Override @Override
default Optional<Data.Items.EnderChest> getEnderChest() { default Optional<Data.Items.EnderChest> getEnderChest() {
return Optional.of(BukkitData.Items.EnderChest.adapt( return Optional.of(BukkitData.Items.EnderChest.adapt(
getMapPersister().persistLockedMaps(getBukkitPlayer().getEnderChest().getContents(), getBukkitPlayer()) getMapPersister().persistLockedMaps(getPlayer().getEnderChest().getContents(), getPlayer())
)); ));
} }
@NotNull @NotNull
@Override @Override
default Optional<Data.PotionEffects> getPotionEffects() { default Optional<Data.PotionEffects> getPotionEffects() {
return Optional.of(BukkitData.PotionEffects.from(getBukkitPlayer().getActivePotionEffects())); return Optional.of(BukkitData.PotionEffects.from(getPlayer().getActivePotionEffects()));
} }
@NotNull @NotNull
@Override @Override
default Optional<Data.Advancements> getAdvancements() { default Optional<Data.Advancements> getAdvancements() {
return Optional.of(BukkitData.Advancements.adapt(getBukkitPlayer())); return Optional.of(BukkitData.Advancements.adapt(getPlayer()));
} }
@NotNull @NotNull
@Override @Override
default Optional<Data.Location> getLocation() { default Optional<Data.Location> getLocation() {
return Optional.of(BukkitData.Location.adapt(getBukkitPlayer().getLocation())); return Optional.of(BukkitData.Location.adapt(getPlayer().getLocation()));
} }
@NotNull @NotNull
@Override @Override
default Optional<Data.Statistics> getStatistics() { default Optional<Data.Statistics> getStatistics() {
return Optional.of(BukkitData.Statistics.adapt(getBukkitPlayer())); return Optional.of(BukkitData.Statistics.adapt(getPlayer()));
} }
@NotNull @NotNull
@Override @Override
default Optional<Data.Health> getHealth() { default Optional<Data.Health> getHealth() {
return Optional.of(BukkitData.Health.adapt(getBukkitPlayer())); return Optional.of(BukkitData.Health.adapt(getPlayer()));
} }
@NotNull @NotNull
@Override @Override
default Optional<Data.Hunger> getHunger() { default Optional<Data.Hunger> getHunger() {
return Optional.of(BukkitData.Hunger.adapt(getBukkitPlayer())); return Optional.of(BukkitData.Hunger.adapt(getPlayer()));
}
@NotNull
@Override
default Optional<Data.Attributes> getAttributes() {
return Optional.of(BukkitData.Attributes.adapt(getPlayer(), getPlugin()));
} }
@NotNull @NotNull
@Override @Override
default Optional<Data.Experience> getExperience() { default Optional<Data.Experience> getExperience() {
return Optional.of(BukkitData.Experience.adapt(getBukkitPlayer())); return Optional.of(BukkitData.Experience.adapt(getPlayer()));
} }
@NotNull @NotNull
@Override @Override
default Optional<Data.GameMode> getGameMode() { default Optional<Data.GameMode> getGameMode() {
return Optional.of(BukkitData.GameMode.adapt(getBukkitPlayer())); return Optional.of(BukkitData.GameMode.adapt(getPlayer()));
}
@NotNull
@Override
default Optional<Data.FlightStatus> getFlightStatus() {
return Optional.of(BukkitData.FlightStatus.adapt(getPlayer()));
} }
@NotNull @NotNull
@Override @Override
default Optional<Data.PersistentData> getPersistentData() { default Optional<Data.PersistentData> getPersistentData() {
return Optional.of(BukkitData.PersistentData.adapt(getBukkitPlayer().getPersistentDataContainer())); return Optional.of(BukkitData.PersistentData.adapt(getPlayer().getPersistentDataContainer()));
} }
boolean isDead(); boolean isDead();
@NotNull @NotNull
Player getBukkitPlayer(); Player getPlayer();
/**
* @deprecated Use {@link #getPlayer()} instead
*/
@Deprecated(since = "3.6")
@NotNull @NotNull
Map<Identifier, Data> getCustomDataStore(); default Player getBukkitPlayer() {
return getPlayer();
}
@NotNull @NotNull
default BukkitMapPersister getMapPersister() { default BukkitMapPersister getMapPersister() {

View File

@@ -20,57 +20,72 @@
package net.william278.husksync.listener; package net.william278.husksync.listener;
import net.william278.husksync.BukkitHuskSync; import net.william278.husksync.BukkitHuskSync;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.BukkitData; import net.william278.husksync.data.BukkitData;
import net.william278.husksync.user.BukkitUser; import net.william278.husksync.user.BukkitUser;
import net.william278.husksync.user.OnlineUser; import net.william278.husksync.user.OnlineUser;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority; import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityPickupItemEvent;
import org.bukkit.event.entity.PlayerDeathEvent; import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.entity.ProjectileLaunchEvent;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.event.player.PlayerCommandPreprocessEvent; import org.bukkit.event.player.PlayerCommandPreprocessEvent;
import org.bukkit.event.player.PlayerDropItemEvent; import org.bukkit.event.server.MapInitializeEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.world.WorldSaveEvent; import org.bukkit.event.world.WorldSaveEvent;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class BukkitEventListener extends EventListener implements BukkitJoinEventListener, BukkitQuitEventListener, public class BukkitEventListener extends EventListener implements BukkitJoinEventListener, BukkitQuitEventListener,
BukkitDeathEventListener, Listener { BukkitDeathEventListener, Listener {
protected final List<String> blacklistedCommands;
public BukkitEventListener(@NotNull BukkitHuskSync huskSync) { protected LockedHandler lockedHandler;
super(huskSync);
this.blacklistedCommands = huskSync.getSettings().getBlacklistedCommandsWhileLocked(); public BukkitEventListener(@NotNull BukkitHuskSync plugin) {
Bukkit.getServer().getPluginManager().registerEvents(this, huskSync); super(plugin);
}
public void onLoad() {
this.lockedHandler = createLockedHandler((BukkitHuskSync) plugin);
}
public void onEnable() {
getPlugin().getServer().getPluginManager().registerEvents(this, getPlugin());
lockedHandler.onEnable();
}
public void handlePluginDisable() {
super.handlePluginDisable();
lockedHandler.onDisable();
}
@NotNull
private LockedHandler createLockedHandler(@NotNull BukkitHuskSync plugin) {
if (!getPlugin().getSettings().isCancelPackets()) {
return new BukkitLockedEventListener(plugin);
}
if (getPlugin().isDependencyLoaded("PacketEvents")) {
return new BukkitPacketEventsLockedPacketListener(plugin);
} else if (getPlugin().isDependencyLoaded("ProtocolLib")) {
return new BukkitProtocolLibLockedPacketListener(plugin);
}
return new BukkitLockedEventListener(plugin);
} }
@Override @Override
public boolean handleEvent(@NotNull ListenerType type, @NotNull Priority priority) { public boolean handleEvent(@NotNull ListenerType type, @NotNull Priority priority) {
return plugin.getSettings().getEventPriority(type).equals(priority); return plugin.getSettings().getSynchronization().getEventPriority(type).equals(priority);
} }
@Override @Override
public void handlePlayerQuit(@NotNull BukkitUser bukkitUser) { public void handlePlayerQuit(@NotNull BukkitUser bukkitUser) {
final Player player = bukkitUser.getPlayer(); final Player player = bukkitUser.getPlayer();
if (!bukkitUser.isLocked() && !player.getItemOnCursor().getType().isAir()) { final ItemStack itemOnCursor = player.getItemOnCursor();
player.getWorld().dropItem(player.getLocation(), player.getItemOnCursor()); if (!bukkitUser.isLocked() && !itemOnCursor.getType().isAir()) {
player.setItemOnCursor(null); player.setItemOnCursor(null);
player.getWorld().dropItem(player.getLocation(), itemOnCursor);
plugin.debug("Dropped " + itemOnCursor + " for " + player.getName() + " on quit");
} }
super.handlePlayerQuit(bukkitUser); super.handlePlayerQuit(bukkitUser);
} }
@@ -85,13 +100,13 @@ public class BukkitEventListener extends EventListener implements BukkitJoinEven
final OnlineUser user = BukkitUser.adapt(event.getEntity(), plugin); final OnlineUser user = BukkitUser.adapt(event.getEntity(), plugin);
// If the player is locked or the plugin disabling, clear their drops // If the player is locked or the plugin disabling, clear their drops
if (cancelPlayerEvent(user.getUuid())) { if (lockedHandler.cancelPlayerEvent(user.getUuid())) {
event.getDrops().clear(); event.getDrops().clear();
return; return;
} }
// Handle saving player data snapshots on death // Handle saving player data snapshots on death
if (!plugin.getSettings().doSaveOnDeath()) { if (!plugin.getSettings().getSynchronization().getSaveOnDeath().isEnabled()) {
return; return;
} }
@@ -105,7 +120,7 @@ public class BukkitEventListener extends EventListener implements BukkitJoinEven
@EventHandler(ignoreCancelled = true) @EventHandler(ignoreCancelled = true)
public void onWorldSave(@NotNull WorldSaveEvent event) { public void onWorldSave(@NotNull WorldSaveEvent event) {
if (!plugin.getSettings().doSaveOnWorldSave()) { if (!plugin.getSettings().getSynchronization().isSaveOnWorldSave()) {
return; return;
} }
@@ -115,88 +130,28 @@ public class BukkitEventListener extends EventListener implements BukkitJoinEven
.collect(Collectors.toList()))); .collect(Collectors.toList())));
} }
@EventHandler(ignoreCancelled = true)
/* public void onMapInitialize(@NotNull MapInitializeEvent event) {
* Events to cancel if the player has not been set yet if (plugin.getSettings().getSynchronization().isPersistLockedMaps() && event.getMap().isLocked()) {
*/ getPlugin().runAsync(() -> ((BukkitHuskSync) plugin).renderMapFromFile(event.getMap()));
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProjectileLaunch(@NotNull ProjectileLaunchEvent event) {
final Projectile projectile = event.getEntity();
if (projectile.getShooter() instanceof Player player) {
event.setCancelled(cancelPlayerEvent(player.getUniqueId()));
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onDropItem(@NotNull PlayerDropItemEvent event) {
event.setCancelled(cancelPlayerEvent(event.getPlayer().getUniqueId()));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPickupItem(@NotNull EntityPickupItemEvent event) {
if (event.getEntity() instanceof Player player) {
event.setCancelled(cancelPlayerEvent(player.getUniqueId()));
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPlayerInteract(@NotNull PlayerInteractEvent event) {
event.setCancelled(cancelPlayerEvent(event.getPlayer().getUniqueId()));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPlayerInteractEntity(@NotNull PlayerInteractEntityEvent event) {
event.setCancelled(cancelPlayerEvent(event.getPlayer().getUniqueId()));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockPlace(@NotNull BlockPlaceEvent event) {
event.setCancelled(cancelPlayerEvent(event.getPlayer().getUniqueId()));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockBreak(@NotNull BlockBreakEvent event) {
event.setCancelled(cancelPlayerEvent(event.getPlayer().getUniqueId()));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onInventoryOpen(@NotNull InventoryOpenEvent event) {
if (event.getPlayer() instanceof Player player) {
event.setCancelled(cancelPlayerEvent(player.getUniqueId()));
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onInventoryClick(@NotNull InventoryClickEvent event) {
event.setCancelled(cancelPlayerEvent(event.getWhoClicked().getUniqueId()));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCraftItem(@NotNull PrepareItemCraftEvent event) {
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPlayerTakeDamage(@NotNull EntityDamageEvent event) {
if (event.getEntity() instanceof Player player) {
event.setCancelled(cancelPlayerEvent(player.getUniqueId()));
} }
} }
// We handle commands here to allow specific command handling on ProtocolLib servers
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onPermissionCommand(@NotNull PlayerCommandPreprocessEvent event) { public void onCommandProcessed(@NotNull PlayerCommandPreprocessEvent event) {
final String[] commandArgs = event.getMessage().substring(1).split(" "); if (!lockedHandler.isCommandDisabled(event.getMessage().substring(1).split(" ")[0])) {
final String commandLabel = commandArgs[0].toLowerCase(Locale.ENGLISH); return;
}
if (blacklistedCommands.contains("*") || blacklistedCommands.contains(commandLabel)) { if (lockedHandler.cancelPlayerEvent(event.getPlayer().getUniqueId())) {
event.setCancelled(cancelPlayerEvent(event.getPlayer().getUniqueId())); event.setCancelled(true);
} }
} }
@NotNull @NotNull
@Override @Override
public HuskSync getPlugin() { public BukkitHuskSync getPlugin() {
return plugin; return (BukkitHuskSync) plugin;
} }
} }

View File

@@ -0,0 +1,131 @@
/*
* 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.listener;
import lombok.Getter;
import net.william278.husksync.BukkitHuskSync;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.Cancellable;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityPickupItemEvent;
import org.bukkit.event.entity.ProjectileLaunchEvent;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.event.player.PlayerArmorStandManipulateEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
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
public class BukkitLockedEventListener implements LockedHandler, Listener {
protected final BukkitHuskSync plugin;
protected BukkitLockedEventListener(@NotNull BukkitHuskSync plugin) {
this.plugin = plugin;
}
@Override
public void onEnable() {
plugin.getServer().getPluginManager().registerEvents(this, plugin);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProjectileLaunch(@NotNull ProjectileLaunchEvent event) {
final Projectile projectile = event.getEntity();
if (projectile.getShooter() instanceof Player player) {
cancelPlayerEvent(player.getUniqueId(), event);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onDropItem(@NotNull PlayerDropItemEvent event) {
cancelPlayerEvent(event.getPlayer().getUniqueId(), event);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPickupItem(@NotNull EntityPickupItemEvent event) {
if (event.getEntity() instanceof Player player) {
cancelPlayerEvent(player.getUniqueId(), event);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPlayerInteract(@NotNull PlayerInteractEvent event) {
cancelPlayerEvent(event.getPlayer().getUniqueId(), event);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPlayerInteractEntity(@NotNull PlayerInteractEntityEvent event) {
cancelPlayerEvent(event.getPlayer().getUniqueId(), event);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPlayerInteractArmorStand(@NotNull PlayerArmorStandManipulateEvent event) {
cancelPlayerEvent(event.getPlayer().getUniqueId(), event);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockPlace(@NotNull BlockPlaceEvent event) {
cancelPlayerEvent(event.getPlayer().getUniqueId(), event);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockBreak(@NotNull BlockBreakEvent event) {
cancelPlayerEvent(event.getPlayer().getUniqueId(), event);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onInventoryOpen(@NotNull InventoryOpenEvent event) {
if (event.getPlayer() instanceof Player player) {
cancelPlayerEvent(player.getUniqueId(), event);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onInventoryClick(@NotNull InventoryClickEvent event) {
cancelPlayerEvent(event.getWhoClicked().getUniqueId(), event);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPlayerTakeDamage(@NotNull EntityDamageEvent event) {
if (event.getEntity() instanceof Player player) {
cancelPlayerEvent(player.getUniqueId(), event);
}
}
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

@@ -0,0 +1,117 @@
/*
* 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.listener;
import com.github.retrooper.packetevents.PacketEvents;
import com.github.retrooper.packetevents.event.PacketListenerAbstract;
import com.github.retrooper.packetevents.event.PacketListenerPriority;
import com.github.retrooper.packetevents.event.PacketReceiveEvent;
import com.github.retrooper.packetevents.event.PacketSendEvent;
import com.github.retrooper.packetevents.protocol.packettype.PacketType;
import com.google.common.collect.Sets;
import io.github.retrooper.packetevents.factory.spigot.SpigotPacketEventsBuilder;
import net.william278.husksync.BukkitHuskSync;
import org.jetbrains.annotations.NotNull;
import java.util.Set;
import java.util.logging.Level;
public class BukkitPacketEventsLockedPacketListener extends BukkitLockedEventListener implements LockedHandler {
protected BukkitPacketEventsLockedPacketListener(@NotNull BukkitHuskSync plugin) {
super(plugin);
}
@Override
public void onLoad() {
super.onLoad();
PacketEvents.setAPI(SpigotPacketEventsBuilder.build(getPlugin()));
PacketEvents.getAPI().getSettings().reEncodeByDefault(false)
.checkForUpdates(false)
.bStats(true);
PacketEvents.getAPI().load();
}
@Override
public void onEnable() {
super.onEnable();
PacketEvents.getAPI().getEventManager().registerListener(new PlayerPacketAdapter(this));
PacketEvents.getAPI().init();
plugin.log(Level.INFO, "Using PacketEvents to cancel packets for locked players");
}
private static class PlayerPacketAdapter extends PacketListenerAbstract {
private static final Set<PacketType.Play.Client> ALLOWED_PACKETS = Set.of(
PacketType.Play.Client.KEEP_ALIVE, PacketType.Play.Client.PONG, PacketType.Play.Client.PLUGIN_MESSAGE, // Connection packets
PacketType.Play.Client.CHAT_MESSAGE, PacketType.Play.Client.CHAT_COMMAND, PacketType.Play.Client.CHAT_SESSION_UPDATE, // Chat / command packets
PacketType.Play.Client.PLAYER_POSITION, PacketType.Play.Client.PLAYER_POSITION_AND_ROTATION, PacketType.Play.Client.PLAYER_ROTATION, // Movement packets
PacketType.Play.Client.HELD_ITEM_CHANGE, PacketType.Play.Client.ANIMATION, PacketType.Play.Client.TELEPORT_CONFIRM, // Animation packets
PacketType.Play.Client.CLIENT_SETTINGS // Video setting packets
);
private static final Set<PacketType.Play.Client> CANCEL_PACKETS = getPacketsToListenFor();
private final BukkitPacketEventsLockedPacketListener listener;
public PlayerPacketAdapter(@NotNull BukkitPacketEventsLockedPacketListener listener) {
super(PacketListenerPriority.HIGH);
this.listener = listener;
}
@Override
public void onPacketReceive(PacketReceiveEvent event) {
if (!(event.getPacketType() instanceof PacketType.Play.Client client)) {
return;
}
if (!CANCEL_PACKETS.contains(client)) {
return;
}
if (listener.cancelPlayerEvent(event.getUser().getUUID())) {
event.setCancelled(true);
}
}
@Override
public void onPacketSend(PacketSendEvent event) {
if (!(event.getPacketType() instanceof PacketType.Play.Client client)) {
return;
}
if (!CANCEL_PACKETS.contains(client)) {
return;
}
if (listener.cancelPlayerEvent(event.getUser().getUUID())) {
event.setCancelled(true);
}
}
// Returns the set of ALL Server packets, excluding the set of allowed packets
@NotNull
private static Set<PacketType.Play.Client> getPacketsToListenFor() {
return Sets.difference(
Sets.newHashSet(PacketType.Play.Client.values()),
ALLOWED_PACKETS
);
}
}
}

View File

@@ -0,0 +1,93 @@
/*
* 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.listener;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.ProtocolLibrary;
import com.comphenix.protocol.events.ListenerPriority;
import com.comphenix.protocol.events.PacketAdapter;
import com.comphenix.protocol.events.PacketEvent;
import com.google.common.collect.Sets;
import net.william278.husksync.BukkitHuskSync;
import org.jetbrains.annotations.NotNull;
import java.util.Set;
import java.util.logging.Level;
import java.util.stream.Collectors;
import static com.comphenix.protocol.PacketType.Play.Client;
public class BukkitProtocolLibLockedPacketListener extends BukkitLockedEventListener implements LockedHandler {
protected BukkitProtocolLibLockedPacketListener(@NotNull BukkitHuskSync plugin) {
super(plugin);
}
@Override
public void onEnable() {
super.onEnable();
ProtocolLibrary.getProtocolManager().addPacketListener(new PlayerPacketAdapter(this));
plugin.log(Level.INFO, "Using ProtocolLib to cancel packets for locked players");
}
private static class PlayerPacketAdapter extends PacketAdapter {
// Packets we want the player to still be able to send/receiver to/from the server
private static final Set<PacketType> ALLOWED_PACKETS = Set.of(
Client.KEEP_ALIVE, Client.PONG, Client.CUSTOM_PAYLOAD, // Connection packets
Client.CHAT_COMMAND, Client.CLIENT_COMMAND, Client.CHAT, Client.CHAT_SESSION_UPDATE, // Chat / command packets
Client.POSITION, Client.POSITION_LOOK, Client.LOOK, // Movement packets
Client.HELD_ITEM_SLOT, Client.ARM_ANIMATION, Client.TELEPORT_ACCEPT, // Animation packets
Client.SETTINGS // Video setting packets
);
private final BukkitProtocolLibLockedPacketListener listener;
public PlayerPacketAdapter(@NotNull BukkitProtocolLibLockedPacketListener listener) {
super(listener.getPlugin(), ListenerPriority.HIGHEST, getPacketsToListenFor());
this.listener = listener;
}
@Override
public void onPacketReceiving(@NotNull PacketEvent event) {
if (listener.cancelPlayerEvent(event.getPlayer().getUniqueId()) && !event.isReadOnly()) {
event.setCancelled(true);
}
}
@Override
public void onPacketSending(@NotNull PacketEvent event) {
if (listener.cancelPlayerEvent(event.getPlayer().getUniqueId()) && !event.isReadOnly()) {
event.setCancelled(true);
}
}
// Returns the set of ALL Server packets, excluding the set of allowed packets
@NotNull
private static Set<PacketType> getPacketsToListenFor() {
return Sets.difference(
Client.getInstance().values().stream().filter(PacketType::isSupported).collect(Collectors.toSet()),
ALLOWED_PACKETS
);
}
}
}

View File

@@ -19,6 +19,8 @@
package net.william278.husksync.migrator; package net.william278.husksync.migrator;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.zaxxer.hikari.HikariDataSource; import com.zaxxer.hikari.HikariDataSource;
import me.william278.husksync.bukkit.data.DataSerializer; import me.william278.husksync.bukkit.data.DataSerializer;
import net.william278.hslmigrator.HSLConverter; import net.william278.hslmigrator.HSLConverter;
@@ -42,6 +44,8 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import static net.william278.husksync.config.Settings.DatabaseSettings;
public class LegacyMigrator extends Migrator { public class LegacyMigrator extends Migrator {
private final HSLConverter hslConverter; private final HSLConverter hslConverter;
@@ -56,11 +60,13 @@ public class LegacyMigrator extends Migrator {
public LegacyMigrator(@NotNull HuskSync plugin) { public LegacyMigrator(@NotNull HuskSync plugin) {
super(plugin); super(plugin);
this.hslConverter = HSLConverter.getInstance(); this.hslConverter = HSLConverter.getInstance();
this.sourceHost = plugin.getSettings().getMySqlHost();
this.sourcePort = plugin.getSettings().getMySqlPort(); final DatabaseSettings.DatabaseCredentials credentials = plugin.getSettings().getDatabase().getCredentials();
this.sourceUsername = plugin.getSettings().getMySqlUsername(); this.sourceHost = credentials.getHost();
this.sourcePassword = plugin.getSettings().getMySqlPassword(); this.sourcePort = credentials.getPort();
this.sourceDatabase = plugin.getSettings().getMySqlDatabase(); this.sourceUsername = credentials.getUsername();
this.sourcePassword = credentials.getPassword();
this.sourceDatabase = credentials.getDatabase();
this.sourcePlayersTable = "husksync_players"; this.sourcePlayersTable = "husksync_players";
this.sourceDataTable = "husksync_data"; this.sourceDataTable = "husksync_data";
} }
@@ -87,7 +93,7 @@ public class LegacyMigrator extends Migrator {
connectionPool.setPoolName((getIdentifier() + "_migrator_pool").toUpperCase(Locale.ENGLISH)); connectionPool.setPoolName((getIdentifier() + "_migrator_pool").toUpperCase(Locale.ENGLISH));
plugin.log(Level.INFO, "Downloading raw data from the legacy database (this might take a while)..."); plugin.log(Level.INFO, "Downloading raw data from the legacy database (this might take a while)...");
final List<LegacyData> dataToMigrate = new ArrayList<>(); final List<LegacyData> dataToMigrate = Lists.newArrayList();
try (final Connection connection = connectionPool.getConnection()) { try (final Connection connection = connectionPool.getConnection()) {
try (final PreparedStatement statement = connection.prepareStatement(""" try (final PreparedStatement statement = connection.prepareStatement("""
SELECT `uuid`, `username`, `inventory`, `ender_chest`, `health`, `max_health`, `health_scale`, `hunger`, `saturation`, `saturation_exhaustion`, `selected_slot`, `status_effects`, `total_experience`, `exp_level`, `exp_progress`, `game_mode`, `statistics`, `is_flying`, `advancements`, `location` SELECT `uuid`, `username`, `inventory`, `ender_chest`, `health`, `max_health`, `health_scale`, `hunger`, `saturation`, `saturation_exhaustion`, `selected_slot`, `status_effects`, `total_experience`, `exp_level`, `exp_progress`, `game_mode`, `statistics`, `is_flying`, `advancements`, `location`
@@ -198,10 +204,10 @@ public class LegacyMigrator extends Migrator {
}) { }) {
plugin.log(Level.INFO, getHelpMenu()); plugin.log(Level.INFO, getHelpMenu());
plugin.log(Level.INFO, "Successfully set " + args[0] + " to " + plugin.log(Level.INFO, "Successfully set " + args[0] + " to " +
obfuscateDataString(args[1])); obfuscateDataString(args[1]));
} else { } else {
plugin.log(Level.INFO, "Invalid operation, could not set " + args[0] + " to " + plugin.log(Level.INFO, "Invalid operation, could not set " + args[0] + " to " +
obfuscateDataString(args[1]) + " (is it a valid option?)"); obfuscateDataString(args[1]) + " (is it a valid option?)");
} }
} else { } else {
plugin.log(Level.INFO, getHelpMenu()); plugin.log(Level.INFO, getHelpMenu());
@@ -217,16 +223,16 @@ public class LegacyMigrator extends Migrator {
@NotNull @NotNull
@Override @Override
public String getName() { public String getName() {
return "HuskSync v1.x --> v2.x Migrator"; return "HuskSync v1.x --> v3.x Migrator";
} }
@NotNull @NotNull
@Override @Override
public String getHelpMenu() { public String getHelpMenu() {
return """ return """
=== HuskSync v1.x --> v2.x Migration Wizard ========= === HuskSync v1.x --> v3.x Migration Wizard =========
This will migrate all user data from HuskSync v1.x to This will migrate all user data from HuskSync v1.x to
HuskSync v2.x's new format. To perform the migration, HuskSync v3.x's new format. To perform the migration,
please follow the steps below carefully. please follow the steps below carefully.
[!] Existing data in the database will be wiped. [!] [!] Existing data in the database will be wiped. [!]
@@ -317,18 +323,18 @@ public class LegacyMigrator extends Migrator {
// Stats // Stats
.statistics(BukkitData.Statistics.from( .statistics(BukkitData.Statistics.from(
BukkitData.Statistics.createStatisticsMap( convertStatisticMap(stats.untypedStatisticValues()),
convertStatisticMap(stats.untypedStatisticValues()), convertMaterialStatisticMap(stats.blockStatisticValues()),
convertMaterialStatisticMap(stats.blockStatisticValues()), convertMaterialStatisticMap(stats.itemStatisticValues()),
convertMaterialStatisticMap(stats.itemStatisticValues()), convertEntityStatisticMap(stats.entityStatisticValues())
convertEntityStatisticMap(stats.entityStatisticValues()) ))
)))
// Health, hunger, experience & game mode // Health, hunger, experience & game mode
.health(BukkitData.Health.from(health, maxHealth, healthScale)) .health(BukkitData.Health.from(health, healthScale, false))
.hunger(BukkitData.Hunger.from(hunger, saturation, saturationExhaustion)) .hunger(BukkitData.Hunger.from(hunger, saturation, saturationExhaustion))
.experience(BukkitData.Experience.from(totalExp, expLevel, expProgress)) .experience(BukkitData.Experience.from(totalExp, expLevel, expProgress))
.gameMode(BukkitData.GameMode.from(gameMode, isFlying, isFlying)) .gameMode(BukkitData.GameMode.from(gameMode))
.flightStatus(BukkitData.FlightStatus.from(isFlying, isFlying))
// Build & pack into new format // Build & pack into new format
.saveCause(DataSnapshot.SaveCause.LEGACY_MIGRATION).buildAndPack(); .saveCause(DataSnapshot.SaveCause.LEGACY_MIGRATION).buildAndPack();
@@ -338,7 +344,7 @@ public class LegacyMigrator extends Migrator {
} }
private Map<String, Integer> convertStatisticMap(@NotNull HashMap<Statistic, Integer> rawMap) { private Map<String, Integer> convertStatisticMap(@NotNull HashMap<Statistic, Integer> rawMap) {
final HashMap<String, Integer> convertedMap = new HashMap<>(); final HashMap<String, Integer> convertedMap = Maps.newHashMap();
for (Map.Entry<Statistic, Integer> entry : rawMap.entrySet()) { for (Map.Entry<Statistic, Integer> entry : rawMap.entrySet()) {
convertedMap.put(entry.getKey().getKey().toString(), entry.getValue()); convertedMap.put(entry.getKey().getKey().toString(), entry.getValue());
} }
@@ -346,7 +352,7 @@ public class LegacyMigrator extends Migrator {
} }
private Map<String, Map<String, Integer>> convertMaterialStatisticMap(@NotNull HashMap<Statistic, HashMap<Material, Integer>> rawMap) { private Map<String, Map<String, Integer>> convertMaterialStatisticMap(@NotNull HashMap<Statistic, HashMap<Material, Integer>> rawMap) {
final Map<String, Map<String, Integer>> convertedMap = new HashMap<>(); final Map<String, Map<String, Integer>> convertedMap = Maps.newHashMap();
for (Map.Entry<Statistic, HashMap<Material, Integer>> entry : rawMap.entrySet()) { for (Map.Entry<Statistic, HashMap<Material, Integer>> entry : rawMap.entrySet()) {
for (Map.Entry<Material, Integer> materialEntry : entry.getValue().entrySet()) { for (Map.Entry<Material, Integer> materialEntry : entry.getValue().entrySet()) {
convertedMap.computeIfAbsent(entry.getKey().getKey().toString(), k -> new HashMap<>()) convertedMap.computeIfAbsent(entry.getKey().getKey().toString(), k -> new HashMap<>())
@@ -357,7 +363,7 @@ public class LegacyMigrator extends Migrator {
} }
private Map<String, Map<String, Integer>> convertEntityStatisticMap(@NotNull HashMap<Statistic, HashMap<EntityType, Integer>> rawMap) { private Map<String, Map<String, Integer>> convertEntityStatisticMap(@NotNull HashMap<Statistic, HashMap<EntityType, Integer>> rawMap) {
final Map<String, Map<String, Integer>> convertedMap = new HashMap<>(); final Map<String, Map<String, Integer>> convertedMap = Maps.newHashMap();
for (Map.Entry<Statistic, HashMap<EntityType, Integer>> entry : rawMap.entrySet()) { for (Map.Entry<Statistic, HashMap<EntityType, Integer>> entry : rawMap.entrySet()) {
for (Map.Entry<EntityType, Integer> materialEntry : entry.getValue().entrySet()) { for (Map.Entry<EntityType, Integer> materialEntry : entry.getValue().entrySet()) {
convertedMap.computeIfAbsent(entry.getKey().getKey().toString(), k -> new HashMap<>()) convertedMap.computeIfAbsent(entry.getKey().getKey().toString(), k -> new HashMap<>())

View File

@@ -19,6 +19,7 @@
package net.william278.husksync.migrator; package net.william278.husksync.migrator;
import com.google.common.collect.Lists;
import com.zaxxer.hikari.HikariDataSource; import com.zaxxer.hikari.HikariDataSource;
import net.william278.husksync.BukkitHuskSync; import net.william278.husksync.BukkitHuskSync;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
@@ -35,12 +36,17 @@ import org.jetbrains.annotations.NotNull;
import java.sql.Connection; import java.sql.Connection;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.util.*; import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import static net.william278.husksync.config.Settings.DatabaseSettings;
/** /**
* A migrator for migrating MySQLPlayerDataBridge data to HuskSync {@link DataSnapshot}s * A migrator for migrating MySQLPlayerDataBridge data to HuskSync {@link DataSnapshot}s
*/ */
@@ -62,11 +68,12 @@ public class MpdbMigrator extends Migrator {
Bukkit.getPluginManager().getPlugin("MySQLPlayerDataBridge"), Bukkit.getPluginManager().getPlugin("MySQLPlayerDataBridge"),
"MySQLPlayerDataBridge dependency not found!" "MySQLPlayerDataBridge dependency not found!"
)); ));
this.sourceHost = plugin.getSettings().getMySqlHost(); final DatabaseSettings.DatabaseCredentials credentials = plugin.getSettings().getDatabase().getCredentials();
this.sourcePort = plugin.getSettings().getMySqlPort(); this.sourceHost = credentials.getHost();
this.sourceUsername = plugin.getSettings().getMySqlUsername(); this.sourcePort = credentials.getPort();
this.sourcePassword = plugin.getSettings().getMySqlPassword(); this.sourceUsername = credentials.getUsername();
this.sourceDatabase = plugin.getSettings().getMySqlDatabase(); this.sourcePassword = credentials.getPassword();
this.sourceDatabase = credentials.getDatabase();
this.sourceInventoryTable = "mpdb_inventory"; this.sourceInventoryTable = "mpdb_inventory";
this.sourceEnderChestTable = "mpdb_enderchest"; this.sourceEnderChestTable = "mpdb_enderchest";
this.sourceExperienceTable = "mpdb_experience"; this.sourceExperienceTable = "mpdb_experience";
@@ -95,7 +102,7 @@ public class MpdbMigrator extends Migrator {
connectionPool.setPoolName((getIdentifier() + "_migrator_pool").toUpperCase(Locale.ENGLISH)); connectionPool.setPoolName((getIdentifier() + "_migrator_pool").toUpperCase(Locale.ENGLISH));
plugin.log(Level.INFO, "Downloading raw data from the MySQLPlayerDataBridge database (this might take a while)..."); plugin.log(Level.INFO, "Downloading raw data from the MySQLPlayerDataBridge database (this might take a while)...");
final List<MpdbData> dataToMigrate = new ArrayList<>(); final List<MpdbData> dataToMigrate = Lists.newArrayList();
try (final Connection connection = connectionPool.getConnection()) { try (final Connection connection = connectionPool.getConnection()) {
try (final PreparedStatement statement = connection.prepareStatement(""" try (final PreparedStatement statement = connection.prepareStatement("""
SELECT `%source_inventory_table%`.`player_uuid`, `%source_inventory_table%`.`player_name`, `inventory`, `armor`, `enderchest`, `exp_lvl`, `exp`, `total_exp` SELECT `%source_inventory_table%`.`player_uuid`, `%source_inventory_table%`.`player_name`, `inventory`, `armor`, `enderchest`, `exp_lvl`, `exp`, `total_exp`
@@ -143,7 +150,7 @@ public class MpdbMigrator extends Migrator {
}); });
plugin.log(Level.INFO, "Migration complete for " + dataToMigrate.size() + " users in " + ((System.currentTimeMillis() - startTime) / 1000) + " seconds!"); plugin.log(Level.INFO, "Migration complete for " + dataToMigrate.size() + " users in " + ((System.currentTimeMillis() - startTime) / 1000) + " seconds!");
return true; return true;
} catch (Exception e) { } catch (Throwable e) {
plugin.log(Level.SEVERE, "Error while migrating data: " + e.getMessage() + " - are your source database credentials correct?"); plugin.log(Level.SEVERE, "Error while migrating data: " + e.getMessage() + " - are your source database credentials correct?");
return false; return false;
} }
@@ -194,10 +201,10 @@ public class MpdbMigrator extends Migrator {
}) { }) {
plugin.log(Level.INFO, getHelpMenu()); plugin.log(Level.INFO, getHelpMenu());
plugin.log(Level.INFO, "Successfully set " + args[0] + " to " + plugin.log(Level.INFO, "Successfully set " + args[0] + " to " +
obfuscateDataString(args[1])); obfuscateDataString(args[1]));
} else { } else {
plugin.log(Level.INFO, "Invalid operation, could not set " + args[0] + " to " + plugin.log(Level.INFO, "Invalid operation, could not set " + args[0] + " to " +
obfuscateDataString(args[1]) + " (is it a valid option?)"); obfuscateDataString(args[1]) + " (is it a valid option?)");
} }
} else { } else {
plugin.log(Level.INFO, getHelpMenu()); plugin.log(Level.INFO, getHelpMenu());
@@ -221,6 +228,9 @@ public class MpdbMigrator extends Migrator {
public String getHelpMenu() { public String getHelpMenu() {
return """ return """
=== MySQLPlayerDataBridge Migration Wizard ========== === MySQLPlayerDataBridge Migration Wizard ==========
NOTE: This migrator currently WORKS WITH MPDB version
v4.9.2 and below!
This will migrate inventories, ender chests and XP This will migrate inventories, ender chests and XP
from the MySQLPlayerDataBridge plugin to HuskSync. from the MySQLPlayerDataBridge plugin to HuskSync.
@@ -245,7 +255,7 @@ public class MpdbMigrator extends Migrator {
If any of these are not correct, please correct them If any of these are not correct, please correct them
using the command: using the command:
"husksync migrate mpdb set <parameter> <value>" "husksync migrate mpdb set <parameter> <value>"
(e.g.: "husksync migrate mpdb set host 1.2.3.4") (e.g.: "husksync migrate set mpdb host 1.2.3.4")
STEP 3] HuskSync will migrate data into the database STEP 3] HuskSync will migrate data into the database
tables configures in the config.yml file of this tables configures in the config.yml file of this
@@ -253,7 +263,10 @@ public class MpdbMigrator extends Migrator {
before proceeding. before proceeding.
STEP 4] To start the migration, please run: STEP 4] To start the migration, please run:
"husksync migrate mpdb start" "husksync migrate start mpdb"
NOTE: This migrator currently WORKS WITH MPDB version
v4.9.2 and below!
""".replaceAll(Pattern.quote("%source_host%"), obfuscateDataString(sourceHost)) """.replaceAll(Pattern.quote("%source_host%"), obfuscateDataString(sourceHost))
.replaceAll(Pattern.quote("%source_port%"), Integer.toString(sourcePort)) .replaceAll(Pattern.quote("%source_port%"), Integer.toString(sourcePort))
.replaceAll(Pattern.quote("%source_username%"), obfuscateDataString(sourceUsername)) .replaceAll(Pattern.quote("%source_username%"), obfuscateDataString(sourceUsername))
@@ -307,7 +320,7 @@ public class MpdbMigrator extends Migrator {
.inventory(BukkitData.Items.Inventory.from(inventory.getContents(), 0)) .inventory(BukkitData.Items.Inventory.from(inventory.getContents(), 0))
.enderChest(BukkitData.Items.EnderChest.adapt(enderChest)) .enderChest(BukkitData.Items.EnderChest.adapt(enderChest))
.experience(BukkitData.Experience.from(totalExp, expLevel, expProgress)) .experience(BukkitData.Experience.from(totalExp, expLevel, expProgress))
.gameMode(BukkitData.GameMode.from("SURVIVAL", false, false)) .gameMode(BukkitData.GameMode.from("SURVIVAL"))
.saveCause(DataSnapshot.SaveCause.MPDB_MIGRATION) .saveCause(DataSnapshot.SaveCause.MPDB_MIGRATION)
.buildAndPack(); .buildAndPack();
} }

View File

@@ -23,15 +23,10 @@ import de.themoep.minedown.adventure.MineDown;
import dev.triumphteam.gui.builder.gui.StorageBuilder; import dev.triumphteam.gui.builder.gui.StorageBuilder;
import dev.triumphteam.gui.guis.Gui; import dev.triumphteam.gui.guis.Gui;
import dev.triumphteam.gui.guis.StorageGui; import dev.triumphteam.gui.guis.StorageGui;
import net.kyori.adventure.audience.Audience;
import net.roxeez.advancement.display.FrameType;
import net.william278.andjam.Toast;
import net.william278.husksync.BukkitHuskSync;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
import net.william278.husksync.data.BukkitData; import net.william278.husksync.data.BukkitData;
import net.william278.husksync.data.BukkitUserDataHolder; import net.william278.husksync.data.BukkitUserDataHolder;
import net.william278.husksync.data.Data; import net.william278.husksync.data.Data;
import org.bukkit.Material;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.ApiStatus;
@@ -61,43 +56,18 @@ public class BukkitUser extends OnlineUser implements BukkitUserDataHolder {
return new BukkitUser(player, plugin); return new BukkitUser(player, plugin);
} }
/**
* Get the Bukkit {@link Player} instance of this user
*
* @return the {@link Player} instance
* @since 3.0
*/
@NotNull
public Player getPlayer() {
return player;
}
@Override @Override
public boolean isOffline() { public boolean isOffline() {
return player == null || !player.isOnline(); return player == null || !player.isOnline();
} }
@NotNull
@Override
public Audience getAudience() {
return ((BukkitHuskSync) plugin).getAudiences().player(player);
}
@Override @Override
@Deprecated(since = "3.6.7")
public void sendToast(@NotNull MineDown title, @NotNull MineDown description, public void sendToast(@NotNull MineDown title, @NotNull MineDown description,
@NotNull String iconMaterial, @NotNull String backgroundType) { @NotNull String iconMaterial, @NotNull String backgroundType) {
try { plugin.log(Level.WARNING, "Toast notifications are deprecated. " +
final Material material = Material.matchMaterial(iconMaterial); "Please change your notification display slot to CHAT, ACTION_BAR or NONE.");
Toast.builder((BukkitHuskSync) plugin) this.sendActionBar(title);
.setTitle(title.toComponent())
.setDescription(description.toComponent())
.setIcon(material != null ? material : Material.BARRIER)
.setFrameType(FrameType.valueOf(backgroundType))
.build()
.show(player);
} catch (Throwable e) {
plugin.log(Level.WARNING, "Failed to send toast to player " + player.getName(), e);
}
} }
@Override @Override
@@ -108,13 +78,13 @@ public class BukkitUser extends OnlineUser implements BukkitUserDataHolder {
if (!editable) { if (!editable) {
builder.disableAllInteractions(); builder.disableAllInteractions();
} }
final StorageGui gui = builder.enableOtherActions() final StorageGui gui = builder
.apply(a -> a.getInventory().setContents(contents)) .apply(a -> a.getInventory().setContents(contents))
.title(title.toComponent()).create(); .title(title.toComponent()).create();
gui.setCloseGuiAction((close) -> onClose.accept(BukkitData.Items.ItemArray.adapt( gui.setCloseGuiAction((close) -> onClose.accept(BukkitData.Items.ItemArray.adapt(
Arrays.stream(close.getInventory().getContents()).limit(size).toArray(ItemStack[]::new) Arrays.stream(close.getInventory().getContents()).limit(size).toArray(ItemStack[]::new)
))); )));
plugin.runSync(() -> gui.open(player)); plugin.runSync(() -> gui.open(player), this);
} }
@Override @Override
@@ -137,9 +107,14 @@ public class BukkitUser extends OnlineUser implements BukkitUserDataHolder {
return player.hasMetadata("NPC"); return player.hasMetadata("NPC");
} }
/**
* Get the Bukkit {@link Player} instance of this user
*
* @return the {@link Player} instance
* @since 3.6
*/
@NotNull @NotNull
@Override public Player getPlayer() {
public Player getBukkitPlayer() {
return player; return player;
} }

View File

@@ -0,0 +1,62 @@
/*
* 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 org.bukkit.*;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.EntityType;
import org.bukkit.potion.PotionEffectType;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
// Utility class for adapting "Keyed" Bukkit objects
public final class BukkitKeyedAdapter {
@Nullable
public static Statistic matchStatistic(@NotNull String key) {
return getRegistryValue(Registry.STATISTIC, key);
}
@Nullable
public static EntityType matchEntityType(@NotNull String key) {
return getRegistryValue(Registry.ENTITY_TYPE, key);
}
@Nullable
public static Material matchMaterial(@NotNull String key) {
return getRegistryValue(Registry.MATERIAL, key);
}
@Nullable
public static Attribute matchAttribute(@NotNull String key) {
return getRegistryValue(Registry.ATTRIBUTE, key);
}
@Nullable
public static PotionEffectType matchEffectType(@NotNull String key) {
return getRegistryValue(Registry.EFFECT, key);
}
private static <T extends Keyed> T getRegistryValue(@NotNull Registry<T> registry, @NotNull String keyString) {
final NamespacedKey key = NamespacedKey.fromString(keyString);
return key != null ? registry.get(key) : null;
}
}

View File

@@ -19,15 +19,14 @@
package net.william278.husksync.util; package net.william278.husksync.util;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.DataAdapter; import net.william278.husksync.adapter.DataAdapter;
import net.william278.husksync.data.BukkitData; import net.william278.husksync.data.BukkitData;
import net.william278.husksync.data.Data; import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot; import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.data.Identifier; import net.william278.husksync.data.Identifier;
import org.bukkit.Material;
import org.bukkit.Statistic;
import org.bukkit.entity.EntityType;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.util.io.BukkitObjectInputStream; import org.bukkit.util.io.BukkitObjectInputStream;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
@@ -43,15 +42,18 @@ import java.time.OffsetDateTime;
import java.util.*; import java.util.*;
import java.util.logging.Level; import java.util.logging.Level;
import static net.william278.husksync.util.BukkitKeyedAdapter.matchEntityType;
import static net.william278.husksync.util.BukkitKeyedAdapter.matchMaterial;
public class BukkitLegacyConverter extends LegacyConverter { public class BukkitLegacyConverter extends LegacyConverter {
public BukkitLegacyConverter(@NotNull HuskSync plugin) { public BukkitLegacyConverter(@NotNull HuskSync plugin) {
super(plugin); super(plugin);
} }
@NotNull
@Override @Override
public DataSnapshot.Packed convert(@NotNull byte[] data, @NotNull UUID id, @NotNull
public DataSnapshot.Packed convert(byte @NotNull [] data, @NotNull UUID id,
@NotNull OffsetDateTime timestamp) throws DataAdapter.AdaptionException { @NotNull OffsetDateTime timestamp) throws DataAdapter.AdaptionException {
final JSONObject object = new JSONObject(plugin.getDataAdapter().bytesToString(data)); final JSONObject object = new JSONObject(plugin.getDataAdapter().bytesToString(data));
final int version = object.getInt("format_version"); final int version = object.getInt("format_version");
@@ -79,31 +81,35 @@ public class BukkitLegacyConverter extends LegacyConverter {
} }
final JSONObject status = object.getJSONObject("status_data"); final JSONObject status = object.getJSONObject("status_data");
final HashMap<Identifier, Data> containers = new HashMap<>(); final HashMap<Identifier, Data> containers = Maps.newHashMap();
if (shouldImport(Identifier.HEALTH)) { if (Identifier.HEALTH.isEnabled()) {
containers.put(Identifier.HEALTH, BukkitData.Health.from( containers.put(Identifier.HEALTH, BukkitData.Health.from(
status.getDouble("health"), status.getDouble("health"),
status.getDouble("max_health"), status.getDouble("health_scale"),
status.getDouble("health_scale") false
)); ));
} }
if (shouldImport(Identifier.HUNGER)) { if (Identifier.HUNGER.isEnabled()) {
containers.put(Identifier.HUNGER, BukkitData.Hunger.from( containers.put(Identifier.HUNGER, BukkitData.Hunger.from(
status.getInt("hunger"), status.getInt("hunger"),
status.getFloat("saturation"), status.getFloat("saturation"),
status.getFloat("saturation_exhaustion") status.getFloat("saturation_exhaustion")
)); ));
} }
if (shouldImport(Identifier.EXPERIENCE)) { if (Identifier.EXPERIENCE.isEnabled()) {
containers.put(Identifier.EXPERIENCE, BukkitData.Experience.from( containers.put(Identifier.EXPERIENCE, BukkitData.Experience.from(
status.getInt("total_experience"), status.getInt("total_experience"),
status.getInt("experience_level"), status.getInt("experience_level"),
status.getFloat("experience_progress") status.getFloat("experience_progress")
)); ));
} }
if (shouldImport(Identifier.GAME_MODE)) { if (Identifier.GAME_MODE.isEnabled()) {
containers.put(Identifier.GAME_MODE, BukkitData.GameMode.from( containers.put(Identifier.GAME_MODE, BukkitData.GameMode.from(
status.getString("game_mode"), status.getString("game_mode")
));
}
if (Identifier.FLIGHT_STATUS.isEnabled()) {
containers.put(Identifier.FLIGHT_STATUS, BukkitData.FlightStatus.from(
status.getBoolean("is_flying"), status.getBoolean("is_flying"),
status.getBoolean("is_flying") status.getBoolean("is_flying")
)); ));
@@ -113,7 +119,7 @@ public class BukkitLegacyConverter extends LegacyConverter {
@NotNull @NotNull
private Optional<Data.Items.Inventory> readInventory(@NotNull JSONObject object) { private Optional<Data.Items.Inventory> readInventory(@NotNull JSONObject object) {
if (!object.has("inventory") || !shouldImport(Identifier.INVENTORY)) { if (!object.has("inventory") || !Identifier.INVENTORY.isEnabled()) {
return Optional.empty(); return Optional.empty();
} }
@@ -125,7 +131,7 @@ public class BukkitLegacyConverter extends LegacyConverter {
@NotNull @NotNull
private Optional<Data.Items.EnderChest> readEnderChest(@NotNull JSONObject object) { private Optional<Data.Items.EnderChest> readEnderChest(@NotNull JSONObject object) {
if (!object.has("ender_chest") || !shouldImport(Identifier.ENDER_CHEST)) { if (!object.has("ender_chest") || !Identifier.ENDER_CHEST.isEnabled()) {
return Optional.empty(); return Optional.empty();
} }
@@ -137,7 +143,7 @@ public class BukkitLegacyConverter extends LegacyConverter {
@NotNull @NotNull
private Optional<Data.Location> readLocation(@NotNull JSONObject object) { private Optional<Data.Location> readLocation(@NotNull JSONObject object) {
if (!object.has("location") || !shouldImport(Identifier.LOCATION)) { if (!object.has("location") || !Identifier.LOCATION.isEnabled()) {
return Optional.empty(); return Optional.empty();
} }
@@ -158,12 +164,12 @@ public class BukkitLegacyConverter extends LegacyConverter {
@NotNull @NotNull
private Optional<Data.Advancements> readAdvancements(@NotNull JSONObject object) { private Optional<Data.Advancements> readAdvancements(@NotNull JSONObject object) {
if (!object.has("advancements") || !shouldImport(Identifier.ADVANCEMENTS)) { if (!object.has("advancements") || !Identifier.ADVANCEMENTS.isEnabled()) {
return Optional.empty(); return Optional.empty();
} }
final JSONArray advancements = object.getJSONArray("advancements"); final JSONArray advancements = object.getJSONArray("advancements");
final List<Data.Advancements.Advancement> converted = new ArrayList<>(); final List<Data.Advancements.Advancement> converted = Lists.newArrayList();
advancements.iterator().forEachRemaining(o -> { advancements.iterator().forEachRemaining(o -> {
final JSONObject advancement = (JSONObject) JSONObject.wrap(o); final JSONObject advancement = (JSONObject) JSONObject.wrap(o);
final String key = advancement.getString("key"); final String key = advancement.getString("key");
@@ -181,7 +187,7 @@ public class BukkitLegacyConverter extends LegacyConverter {
@NotNull @NotNull
private Optional<Data.Statistics> readStatistics(@NotNull JSONObject object) { private Optional<Data.Statistics> readStatistics(@NotNull JSONObject object) {
if (!object.has("statistics") || !shouldImport(Identifier.ADVANCEMENTS)) { if (!object.has("statistics") || !Identifier.STATISTICS.isEnabled()) {
return Optional.empty(); return Optional.empty();
} }
@@ -197,36 +203,47 @@ public class BukkitLegacyConverter extends LegacyConverter {
@NotNull @NotNull
private BukkitData.Statistics readStatisticMaps(@NotNull JSONObject untyped, @NotNull JSONObject blocks, private BukkitData.Statistics readStatisticMaps(@NotNull JSONObject untyped, @NotNull JSONObject blocks,
@NotNull JSONObject items, @NotNull JSONObject entities) { @NotNull JSONObject items, @NotNull JSONObject entities) {
final Map<Statistic, Integer> genericStats = new HashMap<>(); // Read generic stats
untyped.keys().forEachRemaining(stat -> genericStats.put(Statistic.valueOf(stat), untyped.getInt(stat))); final Map<String, Integer> genericStats = Maps.newHashMap();
untyped.keys().forEachRemaining(stat -> genericStats.put(stat, untyped.getInt(stat)));
final Map<Statistic, Map<Material, Integer>> blockStats = new HashMap<>(); // Read block & item stats
blocks.keys().forEachRemaining(stat -> { final Map<String, Map<String, Integer>> blockStats, itemStats, entityStats;
final JSONObject blockStat = blocks.getJSONObject(stat); blockStats = readMaterialStatistics(blocks);
final Map<Material, Integer> blockMap = new HashMap<>(); itemStats = readMaterialStatistics(items);
blockStat.keys().forEachRemaining(block -> blockMap.put(Material.valueOf(block), blockStat.getInt(block)));
blockStats.put(Statistic.valueOf(stat), blockMap);
});
final Map<Statistic, Map<Material, Integer>> itemStats = new HashMap<>(); // Read entity stats
items.keys().forEachRemaining(stat -> { entityStats = Maps.newHashMap();
final JSONObject itemStat = items.getJSONObject(stat);
final Map<Material, Integer> itemMap = new HashMap<>();
itemStat.keys().forEachRemaining(item -> itemMap.put(Material.valueOf(item), itemStat.getInt(item)));
itemStats.put(Statistic.valueOf(stat), itemMap);
});
final Map<Statistic, Map<EntityType, Integer>> entityStats = new HashMap<>();
entities.keys().forEachRemaining(stat -> { entities.keys().forEachRemaining(stat -> {
final JSONObject entityStat = entities.getJSONObject(stat); final JSONObject entityStat = entities.getJSONObject(stat);
final Map<EntityType, Integer> entityMap = new HashMap<>(); final Map<String, Integer> entityMap = Maps.newHashMap();
entityStat.keys().forEachRemaining(entity -> entityMap.put(EntityType.valueOf(entity), entityStat.getInt(entity))); entityStat.keys().forEachRemaining(entity -> {
entityStats.put(Statistic.valueOf(stat), entityMap); if (matchEntityType(entity) != null) {
entityMap.put(entity, entityStat.getInt(entity));
}
});
entityStats.put(stat, entityMap);
}); });
return BukkitData.Statistics.from(genericStats, blockStats, itemStats, entityStats); return BukkitData.Statistics.from(genericStats, blockStats, itemStats, entityStats);
} }
@NotNull
private Map<String, Map<String, Integer>> readMaterialStatistics(@NotNull JSONObject items) {
final Map<String, Map<String, Integer>> itemStats = Maps.newHashMap();
items.keys().forEachRemaining(stat -> {
final JSONObject itemStat = items.getJSONObject(stat);
final Map<String, Integer> itemMap = Maps.newHashMap();
itemStat.keys().forEachRemaining(item -> {
if (matchMaterial(item) != null) {
itemMap.put(item, itemStat.getInt(item));
}
});
itemStats.put(stat, itemMap);
});
return itemStats;
}
// Deserialize a legacy item stack array // Deserialize a legacy item stack array
@NotNull @NotNull
public ItemStack[] deserializeLegacyItemStacks(@NotNull String items) { public ItemStack[] deserializeLegacyItemStacks(@NotNull String items) {
@@ -258,16 +275,12 @@ public class BukkitLegacyConverter extends LegacyConverter {
} }
// Deserialize a single legacy item stack // Deserialize a single legacy item stack
@SuppressWarnings("unchecked")
@Nullable @Nullable
private static ItemStack deserializeLegacyItemStack(@Nullable Object serializedItemStack) { private static ItemStack deserializeLegacyItemStack(@Nullable Object serializedItemStack) {
return serializedItemStack != null ? ItemStack.deserialize((Map<String, Object>) serializedItemStack) : null; return serializedItemStack != null ? ItemStack.deserialize((Map<String, Object>) serializedItemStack) : null;
} }
private boolean shouldImport(@NotNull Identifier type) {
return plugin.getSettings().isSyncFeatureEnabled(type);
}
@NotNull @NotNull
private Date parseDate(@NotNull String dateString) { private Date parseDate(@NotNull String dateString) {
try { try {

View File

@@ -19,23 +19,30 @@
package net.william278.husksync.util; package net.william278.husksync.util;
import com.google.common.collect.Lists;
import de.tr7zw.changeme.nbtapi.NBT; import de.tr7zw.changeme.nbtapi.NBT;
import de.tr7zw.changeme.nbtapi.iface.ReadWriteNBT; import de.tr7zw.changeme.nbtapi.iface.ReadWriteNBT;
import de.tr7zw.changeme.nbtapi.iface.ReadableNBT; import de.tr7zw.changeme.nbtapi.iface.ReadableNBT;
import net.william278.husksync.HuskSync; import net.querz.nbt.io.NBTUtil;
import net.querz.nbt.tag.CompoundTag;
import net.william278.husksync.BukkitHuskSync;
import net.william278.mapdataapi.MapBanner; import net.william278.mapdataapi.MapBanner;
import net.william278.mapdataapi.MapData; import net.william278.mapdataapi.MapData;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.block.Container;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.BlockStateMeta;
import org.bukkit.inventory.meta.MapMeta; import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.map.*; import org.bukkit.map.*;
import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.awt.*; import java.awt.*;
import java.io.File;
import java.util.List; import java.util.List;
import java.util.*; import java.util.*;
import java.util.function.Function; import java.util.function.Function;
@@ -59,7 +66,7 @@ public interface BukkitMapPersister {
*/ */
@NotNull @NotNull
default ItemStack[] persistLockedMaps(@NotNull ItemStack[] items, @NotNull Player delegateRenderer) { default ItemStack[] persistLockedMaps(@NotNull ItemStack[] items, @NotNull Player delegateRenderer) {
if (!getPlugin().getSettings().doPersistLockedMaps()) { if (!getPlugin().getSettings().getSynchronization().isPersistLockedMaps()) {
return items; return items;
} }
return forEachMap(items, map -> this.persistMapView(map, delegateRenderer)); return forEachMap(items, map -> this.persistMapView(map, delegateRenderer));
@@ -71,9 +78,9 @@ public interface BukkitMapPersister {
* @param items the array of {@link ItemStack}s to apply persisted locked maps to * @param items the array of {@link ItemStack}s to apply persisted locked maps to
* @return the array of {@link ItemStack}s with persisted locked maps applied * @return the array of {@link ItemStack}s with persisted locked maps applied
*/ */
@NotNull @Nullable
default ItemStack[] setMapViews(@NotNull ItemStack[] items) { default ItemStack @NotNull [] setMapViews(@Nullable ItemStack @NotNull [] items) {
if (!getPlugin().getSettings().doPersistLockedMaps()) { if (!getPlugin().getSettings().getSynchronization().isPersistLockedMaps()) {
return items; return items;
} }
return forEachMap(items, this::applyMapView); return forEachMap(items, this::applyMapView);
@@ -81,7 +88,7 @@ public interface BukkitMapPersister {
// Perform an operation on each map in an array of ItemStacks // Perform an operation on each map in an array of ItemStacks
@NotNull @NotNull
private ItemStack[] forEachMap(@NotNull ItemStack[] items, @NotNull Function<ItemStack, ItemStack> function) { private ItemStack[] forEachMap(ItemStack[] items, @NotNull Function<ItemStack, ItemStack> function) {
for (int i = 0; i < items.length; i++) { for (int i = 0; i < items.length; i++) {
final ItemStack item = items[i]; final ItemStack item = items[i];
if (item == null) { if (item == null) {
@@ -89,6 +96,9 @@ public interface BukkitMapPersister {
} }
if (item.getType() == Material.FILLED_MAP && item.hasItemMeta()) { if (item.getType() == Material.FILLED_MAP && item.hasItemMeta()) {
items[i] = function.apply(item); items[i] = function.apply(item);
} else if (item.getItemMeta() instanceof BlockStateMeta b && b.getBlockState() instanceof Container box) {
forEachMap(box.getInventory().getContents(), function);
b.setBlockState(box);
} }
} }
return items; return items;
@@ -112,7 +122,8 @@ public interface BukkitMapPersister {
} }
// Render the map // Render the map
final PersistentMapCanvas canvas = new PersistentMapCanvas(view); final int dataVersion = getPlugin().getDataVersion(getPlugin().getMinecraftVersion());
final PersistentMapCanvas canvas = new PersistentMapCanvas(view, dataVersion);
for (MapRenderer renderer : view.getRenderers()) { for (MapRenderer renderer : view.getRenderers()) {
renderer.render(view, canvas, delegateRenderer); renderer.render(view, canvas, delegateRenderer);
getPlugin().debug(String.format("Rendered locked map canvas to view (#%s)", view.getId())); getPlugin().debug(String.format("Rendered locked map canvas to view (#%s)", view.getId()));
@@ -130,18 +141,22 @@ public interface BukkitMapPersister {
@NotNull @NotNull
private ItemStack applyMapView(@NotNull ItemStack map) { private ItemStack applyMapView(@NotNull ItemStack map) {
final int dataVersion = getPlugin().getDataVersion(getPlugin().getMinecraftVersion());
final MapMeta meta = Objects.requireNonNull((MapMeta) map.getItemMeta()); final MapMeta meta = Objects.requireNonNull((MapMeta) map.getItemMeta());
NBT.get(map, nbt -> { NBT.get(map, nbt -> {
if (!nbt.hasTag(MAP_DATA_KEY)) { if (!nbt.hasTag(MAP_DATA_KEY)) {
return nbt; return;
} }
final ReadableNBT mapData = nbt.getCompound(MAP_DATA_KEY); final ReadableNBT mapData = nbt.getCompound(MAP_DATA_KEY);
final ReadableNBT mapIds = nbt.getCompound(MAP_VIEW_ID_MAPPINGS_KEY);
if (mapData == null || mapIds == null) {
return;
}
// Search for an existing map view // Search for an existing map view
final ReadableNBT mapIds = nbt.getCompound(MAP_VIEW_ID_MAPPINGS_KEY);
Optional<String> world = Optional.empty(); Optional<String> world = Optional.empty();
for (String worldUid : mapIds.getKeys()) { for (String worldUid : mapIds.getKeys()) {
world = Bukkit.getWorlds().stream() world = getPlugin().getServer().getWorlds().stream()
.map(w -> w.getUID().toString()).filter(u -> u.equals(worldUid)) .map(w -> w.getUID().toString()).filter(u -> u.equals(worldUid))
.findFirst(); .findFirst();
if (world.isPresent()) { if (world.isPresent()) {
@@ -157,7 +172,7 @@ public interface BukkitMapPersister {
meta.setMapView(view); meta.setMapView(view);
map.setItemMeta(meta); map.setItemMeta(meta);
getPlugin().debug(String.format("View exists (#%s); updated map (UID: %s)", view.getId(), uid)); getPlugin().debug(String.format("View exists (#%s); updated map (UID: %s)", view.getId(), uid));
return nbt; return;
} }
} }
@@ -165,28 +180,85 @@ public interface BukkitMapPersister {
final MapData canvasData; final MapData canvasData;
try { try {
getPlugin().debug("Deserializing map data from NBT and generating view..."); getPlugin().debug("Deserializing map data from NBT and generating view...");
canvasData = MapData.fromByteArray(mapData.getByteArray(MAP_PIXEL_DATA_KEY)); canvasData = MapData.fromByteArray(
dataVersion,
Objects.requireNonNull(mapData.getByteArray(MAP_PIXEL_DATA_KEY), "Pixel data null!"));
} catch (Throwable e) { } catch (Throwable e) {
getPlugin().log(Level.WARNING, "Failed to deserialize map data from NBT", e); getPlugin().log(Level.WARNING, "Failed to deserialize map data from NBT", e);
return nbt; return;
} }
// Add a renderer to the map with the data // Add a renderer to the map with the data and save to file
final MapView view = generateRenderedMap(canvasData); final MapView view = generateRenderedMap(canvasData);
final String worldUid = getDefaultMapWorld().getUID().toString(); final String worldUid = getDefaultMapWorld().getUID().toString();
meta.setMapView(view); meta.setMapView(view);
map.setItemMeta(meta); map.setItemMeta(meta);
saveMapToFile(canvasData, view.getId());
// Set the map view ID in NBT // Set the map view ID in NBT
NBT.modify(map, editable -> { NBT.modify(map, editable -> {
editable.getCompound(MAP_VIEW_ID_MAPPINGS_KEY).setInteger(worldUid, view.getId()); Objects.requireNonNull(editable.getCompound(MAP_VIEW_ID_MAPPINGS_KEY),
"Map view ID mappings compound is null")
.setInteger(worldUid, view.getId());
}); });
getPlugin().debug(String.format("Generated view (#%s) and updated map (UID: %s)", view.getId(), worldUid)); getPlugin().debug(String.format("Generated view (#%s) and updated map (UID: %s)", view.getId(), worldUid));
return nbt;
}); });
return map; return map;
} }
default void renderMapFromFile(@NotNull MapView view) {
final File mapFile = new File(getMapCacheFolder(), view.getId() + ".dat");
if (!mapFile.exists()) {
return;
}
final MapData canvasData;
try {
canvasData = MapData.fromNbt(mapFile);
} catch (Throwable e) {
getPlugin().log(Level.WARNING, "Failed to deserialize map data from file", e);
return;
}
// Create a new map view renderer with the map data color at each pixel
// use view.removeRenderer() to remove all this maps renderers
view.getRenderers().forEach(view::removeRenderer);
view.addRenderer(new PersistentMapRenderer(canvasData));
view.setLocked(true);
view.setScale(MapView.Scale.NORMAL);
view.setTrackingPosition(false);
view.setUnlimitedTracking(false);
// Set the view to the map
setMapView(view);
}
default void saveMapToFile(@NotNull MapData data, int id) {
getPlugin().runAsync(() -> {
final File mapFile = new File(getMapCacheFolder(), id + ".dat");
if (mapFile.exists()) {
return;
}
try {
final CompoundTag rootTag = new CompoundTag();
rootTag.put("data", data.toNBT().getTag());
NBTUtil.write(rootTag, mapFile);
} catch (Throwable e) {
getPlugin().log(Level.WARNING, "Failed to serialize map data to file", e);
}
});
}
@NotNull
private File getMapCacheFolder() {
final File mapCache = new File(getPlugin().getDataFolder(), "maps");
if (!mapCache.exists() && !mapCache.mkdirs()) {
getPlugin().log(Level.WARNING, "Failed to create maps folder");
}
return mapCache;
}
// Sets the renderer of a map, and returns the generated MapView // Sets the renderer of a map, and returns the generated MapView
@NotNull @NotNull
private MapView generateRenderedMap(@NotNull MapData canvasData) { private MapView generateRenderedMap(@NotNull MapData canvasData) {
@@ -207,7 +279,7 @@ public interface BukkitMapPersister {
@NotNull @NotNull
private static World getDefaultMapWorld() { private static World getDefaultMapWorld() {
final World world = Bukkit.getWorlds().get(0); final World world = Bukkit.getWorlds().getFirst();
if (world == null) { if (world == null) {
throw new IllegalStateException("No worlds are loaded on the server!"); throw new IllegalStateException("No worlds are loaded on the server!");
} }
@@ -225,6 +297,7 @@ public interface BukkitMapPersister {
/** /**
* A {@link MapRenderer} that can be used to render persistently serialized {@link MapData} to a {@link MapView} * A {@link MapRenderer} that can be used to render persistently serialized {@link MapData} to a {@link MapView}
*/ */
@SuppressWarnings("deprecation")
class PersistentMapRenderer extends MapRenderer { class PersistentMapRenderer extends MapRenderer {
private final MapData canvasData; private final MapData canvasData;
@@ -245,6 +318,10 @@ public interface BukkitMapPersister {
// Set the map banners and markers // Set the map banners and markers
final MapCursorCollection cursors = canvas.getCursors(); final MapCursorCollection cursors = canvas.getCursors();
while (cursors.size() > 0) {
cursors.removeCursor(cursors.getCursor(0));
}
canvasData.getBanners().forEach(banner -> cursors.addCursor(createBannerCursor(banner))); canvasData.getBanners().forEach(banner -> cursors.addCursor(createBannerCursor(banner)));
canvas.setCursors(cursors); canvas.setCursors(cursors);
} }
@@ -255,7 +332,7 @@ public interface BukkitMapPersister {
return new MapCursor( return new MapCursor(
(byte) banner.getPosition().getX(), (byte) banner.getPosition().getX(),
(byte) banner.getPosition().getZ(), (byte) banner.getPosition().getZ(),
(byte) 0, (byte) 8, // Always rotate banners upright
switch (banner.getColor().toLowerCase(Locale.ENGLISH)) { switch (banner.getColor().toLowerCase(Locale.ENGLISH)) {
case "white" -> MapCursor.Type.BANNER_WHITE; case "white" -> MapCursor.Type.BANNER_WHITE;
case "orange" -> MapCursor.Type.BANNER_ORANGE; case "orange" -> MapCursor.Type.BANNER_ORANGE;
@@ -282,13 +359,16 @@ public interface BukkitMapPersister {
/** /**
* A {@link MapCanvas} implementation used for pre-rendering maps to be converted into {@link MapData} * A {@link MapCanvas} implementation used for pre-rendering maps to be converted into {@link MapData}
*/ */
@SuppressWarnings("deprecation")
class PersistentMapCanvas implements MapCanvas { class PersistentMapCanvas implements MapCanvas {
private final int mapDataVersion;
private final MapView mapView; private final MapView mapView;
private final int[][] pixels = new int[128][128]; private final int[][] pixels = new int[128][128];
private MapCursorCollection cursors; private MapCursorCollection cursors;
private PersistentMapCanvas(@NotNull MapView mapView) { private PersistentMapCanvas(@NotNull MapView mapView, int mapDataVersion) {
this.mapDataVersion = mapDataVersion;
this.mapView = mapView; this.mapView = mapView;
} }
@@ -310,18 +390,38 @@ public interface BukkitMapPersister {
} }
@Override @Override
@Deprecated
public void setPixel(int x, int y, byte color) { public void setPixel(int x, int y, byte color) {
pixels[x][y] = color; pixels[x][y] = color;
} }
@Override @Override
@Deprecated
public byte getPixel(int x, int y) { public byte getPixel(int x, int y) {
return (byte) pixels[x][y]; return (byte) pixels[x][y];
} }
@Override @Override
@Deprecated
public byte getBasePixel(int x, int y) { public byte getBasePixel(int x, int y) {
return getPixel(x, y); return (byte) pixels[x][y];
}
@Override
public void setPixelColor(int x, int y, @Nullable Color color) {
pixels[x][y] = color == null ? -1 : MapPalette.matchColor(color);
}
@Nullable
@Override
public Color getPixelColor(int x, int y) {
return MapPalette.getColor((byte) pixels[x][y]);
}
@NotNull
@Override
public Color getBasePixelColor(int x, int y) {
return MapPalette.getColor((byte) pixels[x][y]);
} }
@Override @Override
@@ -350,21 +450,23 @@ public interface BukkitMapPersister {
*/ */
@NotNull @NotNull
private MapData extractMapData() { private MapData extractMapData() {
final List<MapBanner> banners = new ArrayList<>(); final List<MapBanner> banners = Lists.newArrayList();
final String BANNER_PREFIX = "banner_";
for (int i = 0; i < getCursors().size(); i++) { for (int i = 0; i < getCursors().size(); i++) {
final MapCursor cursor = getCursors().getCursor(i); final MapCursor cursor = getCursors().getCursor(i);
final String type = cursor.getType().name().toLowerCase(Locale.ENGLISH); final String type = cursor.getType().getKey().getKey();
if (type.startsWith("banner_")) { if (type.startsWith(BANNER_PREFIX)) {
banners.add(new MapBanner( banners.add(new MapBanner(
type.replaceAll("banner_", ""), type.replaceAll(BANNER_PREFIX, ""),
cursor.getCaption() == null ? "" : cursor.getCaption(), cursor.getCaption() == null ? "" : cursor.getCaption(),
cursor.getX(), cursor.getX(),
mapView.getWorld() != null ? mapView.getWorld().getSeaLevel() : 128, mapView.getWorld() != null ? mapView.getWorld().getSeaLevel() : 128,
cursor.getY() cursor.getY()
)); ));
} }
} }
return MapData.fromPixels(pixels, getDimension(), (byte) 2, banners, List.of()); return MapData.fromPixels(mapDataVersion, pixels, getDimension(), (byte) 2, banners, List.of());
} }
} }
@@ -373,6 +475,6 @@ public interface BukkitMapPersister {
@ApiStatus.Internal @ApiStatus.Internal
@NotNull @NotNull
HuskSync getPlugin(); BukkitHuskSync getPlugin();
} }

View File

@@ -21,8 +21,11 @@ package net.william278.husksync.util;
import net.william278.husksync.BukkitHuskSync; import net.william278.husksync.BukkitHuskSync;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
import net.william278.husksync.data.UserDataHolder;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import space.arim.morepaperlib.scheduling.AsynchronousScheduler; import space.arim.morepaperlib.scheduling.AsynchronousScheduler;
import space.arim.morepaperlib.scheduling.AttachedScheduler;
import space.arim.morepaperlib.scheduling.RegionalScheduler; import space.arim.morepaperlib.scheduling.RegionalScheduler;
import space.arim.morepaperlib.scheduling.ScheduledTask; import space.arim.morepaperlib.scheduling.ScheduledTask;
@@ -34,9 +37,12 @@ public interface BukkitTask extends Task {
class Sync extends Task.Sync implements BukkitTask { class Sync extends Task.Sync implements BukkitTask {
private ScheduledTask task; private ScheduledTask task;
private final @Nullable UserDataHolder user;
protected Sync(@NotNull HuskSync plugin, @NotNull Runnable runnable, long delayTicks) { protected Sync(@NotNull HuskSync plugin, @NotNull Runnable runnable,
@Nullable UserDataHolder user, long delayTicks) {
super(plugin, runnable, delayTicks); super(plugin, runnable, delayTicks);
this.user = user;
} }
@Override @Override
@@ -57,7 +63,19 @@ public interface BukkitTask extends Task {
return; return;
} }
final RegionalScheduler scheduler = ((BukkitHuskSync) getPlugin()).getRegionalScheduler(); // Use entity-specific scheduler if user is not null
if (user != null) {
final AttachedScheduler scheduler = ((BukkitHuskSync) getPlugin()).getUserSyncScheduler(user);
if (delayTicks > 0) {
this.task = scheduler.runDelayed(runnable, null, delayTicks);
} else {
this.task = scheduler.run(runnable, null);
}
return;
}
// Or default to the global scheduler
final RegionalScheduler scheduler = ((BukkitHuskSync) getPlugin()).getSyncScheduler();
if (delayTicks > 0) { if (delayTicks > 0) {
this.task = scheduler.runDelayed(runnable, delayTicks); this.task = scheduler.runDelayed(runnable, delayTicks);
} else { } else {
@@ -146,8 +164,8 @@ public interface BukkitTask extends Task {
@NotNull @NotNull
@Override @Override
default Task.Sync getSyncTask(@NotNull Runnable runnable, long delayTicks) { default Task.Sync getSyncTask(@NotNull Runnable runnable, @Nullable UserDataHolder user, long delayTicks) {
return new Sync(getPlugin(), runnable, delayTicks); return new Sync(getPlugin(), runnable, user, delayTicks);
} }
@NotNull @NotNull

View File

@@ -1,3 +0,0 @@
inventory {
name brigadier:string single_word;
}

View File

@@ -1,5 +0,0 @@
husksync {
update;
about;
reload;
}

View File

@@ -1,3 +0,0 @@
enderchest {
name brigadier:string single_word;
}

View File

@@ -1,35 +0,0 @@
userdata {
view {
name brigadier:string single_word {
version brigadier:string single_word;
}
}
list {
name brigadier:string single_word {
page brigadier:integer;
}
}
delete {
name brigadier:string single_word {
version brigadier:string single_word;
}
}
restore {
name brigadier:string single_word {
version brigadier:string single_word;
}
}
pin {
name brigadier:string single_word {
version brigadier:string single_word;
}
}
dump {
name brigadier:string single_word {
version brigadier:string single_word {
web;
file;
}
}
}
}

View File

@@ -1,15 +1,20 @@
name: 'HuskSync' name: 'HuskSync'
version: '${version}' version: '${version}'
main: 'net.william278.husksync.BukkitHuskSync' main: 'net.william278.husksync.BukkitHuskSync'
api-version: 1.16 api-version: 1.17
author: 'William278' author: 'William278'
description: '${description}' description: '${description}'
website: 'https://william278.net' website: 'https://william278.net'
folia-supported: true
softdepend: softdepend:
- 'packetevents'
- 'ProtocolLib'
- 'MysqlPlayerDataBridge' - 'MysqlPlayerDataBridge'
- 'Plan' - 'Plan'
libraries: libraries:
- 'redis.clients:jedis:${jedis_version}' - 'redis.clients:jedis:${jedis_version}'
- 'com.mysql:mysql-connector-j:${mysql_driver_version}' - 'com.mysql:mysql-connector-j:${mysql_driver_version}'
- 'org.mariadb.jdbc:mariadb-java-client:${mariadb_driver_version}' - 'org.mariadb.jdbc:mariadb-java-client:${mariadb_driver_version}'
- 'org.postgresql:postgresql:${postgres_driver_version}'
- 'org.mongodb:mongodb-driver-sync:${mongodb_driver_version}'
- 'org.xerial.snappy:snappy-java:${snappy_version}' - 'org.xerial.snappy:snappy-java:${snappy_version}'

View File

@@ -3,31 +3,40 @@ plugins {
} }
dependencies { dependencies {
api 'commons-io:commons-io:2.13.0' api 'commons-io:commons-io:2.18.0'
api 'org.apache.commons:commons-text:1.10.0' api 'org.apache.commons:commons-text:1.12.0'
api 'de.themoep:minedown-adventure:1.7.2-SNAPSHOT' api 'net.william278:minedown:1.8.2'
api 'net.kyori:adventure-api:4.14.0' api 'org.json:json:20240303'
api 'org.json:json:20230618' api 'com.google.code.gson:gson:2.11.0'
api 'com.google.code.gson:gson:2.10.1'
api 'com.fatboyindustrial.gson-javatime-serialisers:gson-javatime-serialisers:1.1.2' api 'com.fatboyindustrial.gson-javatime-serialisers:gson-javatime-serialisers:1.1.2'
api 'dev.dejvokep:boosted-yaml:1.3.1' api 'de.exlll:configlib-yaml:4.5.0'
api 'net.william278:annotaml:2.0.7' api 'net.william278:paginedown:1.1.2'
api 'net.william278:DesertWell:2.0.4' api 'net.william278:DesertWell:2.0.4'
api 'net.william278:PagineDown:1.1' api('com.zaxxer:HikariCP:6.2.1') {
api('com.zaxxer:HikariCP:5.0.1') {
exclude module: 'slf4j-api' exclude module: 'slf4j-api'
} }
compileOnly 'org.jetbrains:annotations:24.0.1' compileOnly 'net.william278.uniform:uniform-common:1.2.3'
compileOnly 'com.mojang:brigadier:1.1.8'
compileOnly 'org.projectlombok:lombok:1.18.36'
compileOnly 'org.jetbrains:annotations:26.0.1'
compileOnly 'net.kyori:adventure-api:4.17.0'
compileOnly 'net.kyori:adventure-platform-api:4.3.4'
compileOnly 'com.google.guava:guava:33.3.1-jre'
compileOnly 'com.github.plan-player-analytics:Plan:5.5.2272' compileOnly 'com.github.plan-player-analytics:Plan:5.5.2272'
compileOnly "redis.clients:jedis:$jedis_version" compileOnly "redis.clients:jedis:$jedis_version"
compileOnly "com.mysql:mysql-connector-j:$mysql_driver_version" compileOnly "com.mysql:mysql-connector-j:$mysql_driver_version"
compileOnly "org.mariadb.jdbc:mariadb-java-client:$mariadb_driver_version" compileOnly "org.mariadb.jdbc:mariadb-java-client:$mariadb_driver_version"
compileOnly "org.postgresql:postgresql:$postgres_driver_version"
compileOnly "org.mongodb:mongodb-driver-sync:$mongodb_driver_version"
compileOnly "org.xerial.snappy:snappy-java:$snappy_version" compileOnly "org.xerial.snappy:snappy-java:$snappy_version"
testImplementation 'com.github.plan-player-analytics:Plan:5.5.2272'
testImplementation "redis.clients:jedis:$jedis_version" testImplementation "redis.clients:jedis:$jedis_version"
testImplementation "org.xerial.snappy:snappy-java:$snappy_version" testImplementation "org.xerial.snappy:snappy-java:$snappy_version"
testCompileOnly 'dev.dejvokep:boosted-yaml:1.3.1' testImplementation 'com.google.guava:guava:33.3.1-jre'
testCompileOnly 'org.jetbrains:annotations:24.0.1' testImplementation 'com.github.plan-player-analytics:Plan:5.5.2272'
testCompileOnly 'de.exlll:configlib-yaml:4.5.0'
testCompileOnly 'org.jetbrains:annotations:26.0.1'
annotationProcessor 'org.projectlombok:lombok:1.18.36'
} }

View File

@@ -22,37 +22,39 @@ package net.william278.husksync;
import com.fatboyindustrial.gsonjavatime.Converters; import com.fatboyindustrial.gsonjavatime.Converters;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.GsonBuilder; import com.google.gson.GsonBuilder;
import net.william278.annotaml.Annotaml; import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.platform.AudienceProvider;
import net.william278.desertwell.util.ThrowingConsumer; import net.william278.desertwell.util.ThrowingConsumer;
import net.william278.desertwell.util.UpdateChecker; import net.william278.desertwell.util.UpdateChecker;
import net.william278.desertwell.util.Version; import net.william278.desertwell.util.Version;
import net.william278.husksync.adapter.DataAdapter; import net.william278.husksync.adapter.DataAdapter;
import net.william278.husksync.config.Locales; import net.william278.husksync.config.ConfigProvider;
import net.william278.husksync.config.Settings;
import net.william278.husksync.data.Data; import net.william278.husksync.data.Data;
import net.william278.husksync.data.Identifier; import net.william278.husksync.data.Identifier;
import net.william278.husksync.data.Serializer; import net.william278.husksync.data.SerializerRegistry;
import net.william278.husksync.database.Database; import net.william278.husksync.database.Database;
import net.william278.husksync.event.EventDispatcher; import net.william278.husksync.event.EventDispatcher;
import net.william278.husksync.migrator.Migrator; import net.william278.husksync.migrator.Migrator;
import net.william278.husksync.redis.RedisManager; import net.william278.husksync.redis.RedisManager;
import net.william278.husksync.sync.DataSyncer;
import net.william278.husksync.user.ConsoleUser; import net.william278.husksync.user.ConsoleUser;
import net.william278.husksync.user.OnlineUser; import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.util.CompatibilityChecker;
import net.william278.husksync.util.LegacyConverter; import net.william278.husksync.util.LegacyConverter;
import net.william278.husksync.util.Task; import net.william278.husksync.util.Task;
import net.william278.uniform.Uniform;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.lang.reflect.InvocationTargetException; import java.text.SimpleDateFormat;
import java.util.*; import java.util.*;
import java.util.logging.Level; import java.util.logging.Level;
/** /**
* Abstract implementation of the HuskSync plugin. * Abstract implementation of the HuskSync plugin.
*/ */
public interface HuskSync extends Task.Supplier, EventDispatcher { public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider, SerializerRegistry,
CompatibilityChecker {
int SPIGOT_RESOURCE_ID = 97144; int SPIGOT_RESOURCE_ID = 97144;
@@ -86,49 +88,39 @@ public interface HuskSync extends Task.Supplier, EventDispatcher {
* *
* @return the {@link RedisManager} implementation * @return the {@link RedisManager} implementation
*/ */
@NotNull @NotNull
RedisManager getRedisManager(); RedisManager getRedisManager();
/**
* Returns the implementing adapter for serializing data
*
* @return the {@link DataAdapter}
*/
@NotNull @NotNull
DataAdapter getDataAdapter(); DataAdapter getDataAdapter();
/** /**
* Returns the data serializer for the given {@link Identifier} * Returns the data syncer implementation
*
* @return the {@link DataSyncer} implementation
*/ */
@NotNull @NotNull
<T extends Data> Map<Identifier, Serializer<T>> getSerializers(); DataSyncer getDataSyncer();
/** /**
* Register a data serializer for the given {@link Identifier} * Set the data syncer implementation
* *
* @param identifier the {@link Identifier} * @param dataSyncer the {@link DataSyncer} implementation
* @param serializer the {@link Serializer}
*/ */
default void registerSerializer(@NotNull Identifier identifier, void setDataSyncer(@NotNull DataSyncer dataSyncer);
@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 * Get the uniform command provider
*/
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 * @return the command provider
*/ */
@NotNull @NotNull
default Set<Identifier> getRegisteredDataTypes() { Uniform getUniform();
return getSerializers().keySet();
}
/** /**
* Returns a list of available data {@link Migrator}s * Returns a list of available data {@link Migrator}s
@@ -139,7 +131,17 @@ public interface HuskSync extends Task.Supplier, EventDispatcher {
List<Migrator> getAvailableMigrators(); List<Migrator> getAvailableMigrators();
@NotNull @NotNull
Map<Identifier, Data> getPlayerCustomDataStore(@NotNull OnlineUser user); Map<UUID, Map<Identifier, Data>> getPlayerCustomDataStore();
@NotNull
default Map<Identifier, Data> getPlayerCustomDataStore(@NotNull OnlineUser user) {
if (getPlayerCustomDataStore().containsKey(user.getUuid())) {
return getPlayerCustomDataStore().get(user.getUuid());
}
final Map<Identifier, Data> data = new HashMap<>();
getPlayerCustomDataStore().put(user.getUuid(), data);
return data;
}
/** /**
* Initialize a faucet of the plugin. * Initialize a faucet of the plugin.
@@ -157,26 +159,6 @@ public interface HuskSync extends Task.Supplier, EventDispatcher {
log(Level.INFO, "Successfully initialized " + name); log(Level.INFO, "Successfully initialized " + name);
} }
/**
* Returns the plugin {@link Settings}
*
* @return the {@link Settings}
*/
@NotNull
Settings getSettings();
void setSettings(@NotNull Settings settings);
/**
* Returns the plugin {@link Locales}
*
* @return the {@link Locales}
*/
@NotNull
Locales getLocales();
void setLocales(@NotNull Locales locales);
/** /**
* Returns if a dependency is loaded * Returns if a dependency is loaded
* *
@@ -193,14 +175,6 @@ public interface HuskSync extends Task.Supplier, EventDispatcher {
*/ */
InputStream getResource(@NotNull String name); 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 * Log a message to the console
* *
@@ -217,18 +191,47 @@ public interface HuskSync extends Task.Supplier, EventDispatcher {
* @param throwable a throwable to log * @param throwable a throwable to log
*/ */
default void debug(@NotNull String message, @NotNull Throwable... throwable) { default void debug(@NotNull String message, @NotNull Throwable... throwable) {
if (getSettings().doDebugLogging()) { if (getSettings().isDebugLogging()) {
log(Level.INFO, String.format("[DEBUG] %s", message), throwable); log(Level.INFO, getDebugString(message), throwable);
} }
} }
// Get the debug log message format
@NotNull
private String getDebugString(@NotNull String message) {
return String.format("[DEBUG] [%s] %s", new SimpleDateFormat("mm:ss.SSS").format(new Date()), message);
}
/** /**
* Get the console user * Get the {@link AudienceProvider} instance
* *
* @return the {@link ConsoleUser} * @return the {@link AudienceProvider} instance
* @since 1.0
*/ */
@NotNull @NotNull
ConsoleUser getConsole(); AudienceProvider getAudiences();
/**
* Get the {@link Audience} instance for the given {@link OnlineUser}
*
* @param user the {@link OnlineUser} to get the {@link Audience} for
* @return the {@link Audience} instance
*/
@NotNull
default Audience getAudience(@NotNull UUID user) {
return getAudiences().player(user);
}
/**
* Get the {@link ConsoleUser} instance
*
* @return the {@link ConsoleUser} instance
* @since 1.0
*/
@NotNull
default ConsoleUser getConsole() {
return new ConsoleUser(getAudiences());
}
/** /**
* Returns the plugin version * Returns the plugin version
@@ -246,6 +249,14 @@ public interface HuskSync extends Task.Supplier, EventDispatcher {
@NotNull @NotNull
Version getMinecraftVersion(); Version getMinecraftVersion();
/**
* Returns the data version for a Minecraft version
*
* @param minecraftVersion the Minecraft version
* @return the data version int
*/
int getDataVersion(@NotNull Version minecraftVersion);
/** /**
* Returns the platform type * Returns the platform type
* *
@@ -255,34 +266,20 @@ public interface HuskSync extends Task.Supplier, EventDispatcher {
String getPlatformType(); String getPlatformType();
/** /**
* Returns the legacy data converter, if it exists * Returns the server software version
*
* @return the server software version string
*/
@NotNull
String getServerVersion();
/**
* Returns the legacy data converter if it exists
* *
* @return the {@link LegacyConverter} * @return the {@link LegacyConverter}
*/ */
Optional<LegacyConverter> getLegacyConverter(); 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 @NotNull
default UpdateChecker getUpdateChecker() { default UpdateChecker getUpdateChecker() {
return UpdateChecker.builder() return UpdateChecker.builder()
@@ -293,7 +290,7 @@ public interface HuskSync extends Task.Supplier, EventDispatcher {
} }
default void checkForUpdates() { default void checkForUpdates() {
if (getSettings().doCheckForUpdates()) { if (getSettings().isCheckForUpdates()) {
getUpdateChecker().check().thenAccept(checked -> { getUpdateChecker().check().thenAccept(checked -> {
if (!checked.isUpToDate()) { if (!checked.isUpToDate()) {
log(Level.WARNING, String.format( log(Level.WARNING, String.format(
@@ -305,12 +302,31 @@ public interface HuskSync extends Task.Supplier, EventDispatcher {
} }
} }
/**
* Get the set of UUIDs of "locked players", for which events will be canceled.
* </p>
* Players are locked while their items are being set (on join) or saved (on quit)
*/
@NotNull @NotNull
Set<UUID> getLockedPlayers(); Set<UUID> getLockedPlayers();
default boolean isLocked(@NotNull UUID uuid) {
return getLockedPlayers().contains(uuid);
}
default void lockPlayer(@NotNull UUID uuid) {
getLockedPlayers().add(uuid);
}
default void unlockPlayer(@NotNull UUID uuid) {
getLockedPlayers().remove(uuid);
}
@NotNull @NotNull
Gson getGson(); Gson getGson();
boolean isDisabling();
@NotNull @NotNull
default Gson createGson() { default Gson createGson() {
return Converters.registerOffsetDateTime(new GsonBuilder()).create(); return Converters.registerOffsetDateTime(new GsonBuilder()).create();
@@ -325,14 +341,18 @@ public interface HuskSync extends Task.Supplier, EventDispatcher {
HuskSync has failed to load! The plugin will not be enabled and no data will be synchronized. 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): 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 1) Make sure you've entered your MySQL, MariaDB or MongoDB database details correctly in config.yml
2) Make sure your Redis server details are also correct 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) 3) Make sure your config is up-to-date (https://william278.net/docs/husksync/config-file)
4) Check the error below for more details 4) Check the error below for more details
Caused by: %s"""; Caused by: %s""";
FailedToLoadException(@NotNull String message, @NotNull Throwable cause) { public FailedToLoadException(@NotNull String message) {
super(String.format(FORMAT, message));
}
public FailedToLoadException(@NotNull String message, @NotNull Throwable cause) {
super(String.format(FORMAT, message), cause); super(String.format(FORMAT, message), cause);
} }

View File

@@ -49,7 +49,7 @@ public class GsonAdapter implements DataAdapter {
@Override @Override
@NotNull @NotNull
public <A extends Adaptable> A fromBytes(@NotNull byte[] data, @NotNull Class<A> type) throws AdaptionException { public <A extends Adaptable> A fromBytes(byte[] data, @NotNull Class<A> type) throws AdaptionException {
return this.fromJson(new String(data, StandardCharsets.UTF_8), type); return this.fromJson(new String(data, StandardCharsets.UTF_8), type);
} }

View File

@@ -31,7 +31,6 @@ public class SnappyGsonAdapter extends GsonAdapter {
super(plugin); super(plugin);
} }
@NotNull
@Override @Override
public <A extends Adaptable> byte[] toBytes(@NotNull A data) throws AdaptionException { public <A extends Adaptable> byte[] toBytes(@NotNull A data) throws AdaptionException {
try { try {
@@ -43,7 +42,7 @@ public class SnappyGsonAdapter extends GsonAdapter {
@NotNull @NotNull
@Override @Override
public <A extends Adaptable> A fromBytes(@NotNull byte[] data, @NotNull Class<A> type) throws AdaptionException { public <A extends Adaptable> A fromBytes(byte[] data, @NotNull Class<A> type) throws AdaptionException {
try { try {
return super.fromBytes(decompressBytes(data), type); return super.fromBytes(decompressBytes(data), type);
} catch (IOException e) { } catch (IOException e) {

View File

@@ -26,25 +26,31 @@ import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot; import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.data.Identifier; import net.william278.husksync.data.Identifier;
import net.william278.husksync.data.Serializer; import net.william278.husksync.data.Serializer;
import net.william278.husksync.sync.DataSyncer;
import net.william278.husksync.user.OnlineUser; import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User; import net.william278.husksync.user.User;
import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.function.BiConsumer;
/** /**
* The base implementation of the HuskSync API, containing cross-platform API calls. * The common implementation of the HuskSync API, containing cross-platform API calls.
* </p> * </p>
* This class should not be used directly, but rather through platform-specific extending API classes. * Retrieve an instance of the API class via {@link #getInstance()}.
* *
* @since 2.0 * @since 2.0
*/ */
@SuppressWarnings("unused") @SuppressWarnings("unused")
public abstract class HuskSyncAPI { public class HuskSyncAPI {
// Instance of the plugin
protected static HuskSyncAPI instance;
/** /**
* <b>(Internal use only)</b> - Instance of the implementing plugin. * <b>(Internal use only)</b> - Instance of the implementing plugin.
@@ -59,6 +65,28 @@ public abstract class HuskSyncAPI {
this.plugin = plugin; this.plugin = plugin;
} }
/**
* Entrypoint to the HuskSync API on the common platform - returns an instance of the API
*
* @return instance of the HuskSync API
* @since 3.3
*/
@NotNull
public static HuskSyncAPI getInstance() {
if (instance == null) {
throw new NotRegisteredException();
}
return instance;
}
/**
* <b>(Internal use only)</b> - Unregister the API for this platform.
*/
@ApiStatus.Internal
public static void unregister() {
instance = null;
}
/** /**
* Get a {@link User} by their UUID * Get a {@link User} by their UUID
* *
@@ -71,6 +99,18 @@ public abstract class HuskSyncAPI {
return plugin.supplyAsync(() -> plugin.getDatabase().getUser(uuid)); return plugin.supplyAsync(() -> plugin.getDatabase().getUser(uuid));
} }
/**
* Get an {@link OnlineUser} by their UUID
*
* @param uuid the UUID of the user to get
* @return The {@link OnlineUser} wrapped in an optional, if they are online on <i>this</i> server.
* @since 3.7.2
*/
@NotNull
public Optional<OnlineUser> getOnlineUser(@NotNull UUID uuid) {
return plugin.getOnlineUser(uuid);
}
/** /**
* Get a {@link User} by their username * Get a {@link User} by their username
* *
@@ -146,6 +186,7 @@ public abstract class HuskSyncAPI {
public void editCurrentData(@NotNull User user, @NotNull ThrowingConsumer<DataSnapshot.Unpacked> editor) { public void editCurrentData(@NotNull User user, @NotNull ThrowingConsumer<DataSnapshot.Unpacked> editor) {
getCurrentData(user).thenAccept(optional -> optional.ifPresent(data -> { getCurrentData(user).thenAccept(optional -> optional.ifPresent(data -> {
editor.accept(data); editor.accept(data);
data.setId(UUID.randomUUID());
setCurrentData(user, data); setCurrentData(user, data);
})); }));
} }
@@ -236,13 +277,32 @@ public abstract class HuskSyncAPI {
* *
* @param user The user to save the data for * @param user The user to save the data for
* @param snapshot The snapshot to save * @param snapshot The snapshot to save
* @param callback A callback to run after the data has been saved (if the DataSaveEvent was not canceled)
* @implNote Note that the {@link net.william278.husksync.event.DataSaveEvent} will be fired unless the
* {@link DataSnapshot.SaveCause#fireDataSaveEvent()} is {@code false}
* @since 3.3.2
*/
public void addSnapshot(@NotNull User user, @NotNull DataSnapshot snapshot,
@Nullable BiConsumer<User, DataSnapshot.Packed> callback) {
plugin.runAsync(() -> plugin.getDataSyncer().saveData(
user,
snapshot instanceof DataSnapshot.Unpacked unpacked
? unpacked.pack(plugin) : (DataSnapshot.Packed) snapshot,
callback
));
}
/**
* Adds a data snapshot to the database
*
* @param user The user to save the data for
* @param snapshot The snapshot to save
* @implNote Note that the {@link net.william278.husksync.event.DataSaveEvent} will be fired unless the
* {@link DataSnapshot.SaveCause#fireDataSaveEvent()} is {@code false}
* @since 3.0 * @since 3.0
*/ */
public void addSnapshot(@NotNull User user, @NotNull DataSnapshot snapshot) { public void addSnapshot(@NotNull User user, @NotNull DataSnapshot snapshot) {
plugin.runAsync(() -> plugin.getDatabase().addSnapshot( this.addSnapshot(user, snapshot, null);
user, snapshot instanceof DataSnapshot.Unpacked unpacked
? unpacked.pack(plugin) : (DataSnapshot.Packed) snapshot
));
} }
/** /**
@@ -330,6 +390,17 @@ public abstract class HuskSyncAPI {
plugin.registerSerializer(identifier, serializer); plugin.registerSerializer(identifier, serializer);
} }
/**
* Get a registered data serializer by its identifier
*
* @param identifier The identifier of the data type to get the serializer for
* @return The serializer for the given identifier, or an empty optional if the serializer isn't registered
* @since 3.5.4
*/
public Optional<Serializer<Data>> getDataSerializer(@NotNull Identifier identifier) {
return plugin.getSerializer(identifier);
}
/** /**
* Get a {@link DataSnapshot.Unpacked} from a {@link DataSnapshot.Packed} * Get a {@link DataSnapshot.Unpacked} from a {@link DataSnapshot.Packed}
* *
@@ -404,6 +475,7 @@ public abstract class HuskSyncAPI {
* @param <T> The type of the element * @param <T> The type of the element
* @return The deserialized element * @return The deserialized element
* @throws Serializer.DeserializationException If the element could not be deserialized * @throws Serializer.DeserializationException If the element could not be deserialized
* @since 3.0
*/ */
@NotNull @NotNull
public <T extends Adaptable> T deserializeData(@NotNull String serialized, Class<T> type) public <T extends Adaptable> T deserializeData(@NotNull String serialized, Class<T> type)
@@ -418,6 +490,7 @@ public abstract class HuskSyncAPI {
* @param <T> The type of the element * @param <T> The type of the element
* @return The serialized JSON string * @return The serialized JSON string
* @throws Serializer.SerializationException If the element could not be serialized * @throws Serializer.SerializationException If the element could not be serialized
* @since 3.0
*/ */
@NotNull @NotNull
public <T extends Adaptable> String serializeData(@NotNull T element) public <T extends Adaptable> String serializeData(@NotNull T element)
@@ -425,6 +498,16 @@ public abstract class HuskSyncAPI {
return plugin.getDataAdapter().toJson(element); return plugin.getDataAdapter().toJson(element);
} }
/**
* Set the {@link DataSyncer} to be used to sync data
*
* @param syncer The data syncer to use for synchronizing user data
* @since 3.1
*/
public void setDataSyncer(@NotNull DataSyncer syncer) {
plugin.setDataSyncer(syncer);
}
/** /**
* <b>(Internal use only)</b> - Get the plugin instance * <b>(Internal use only)</b> - Get the plugin instance
* *
@@ -440,17 +523,19 @@ public abstract class HuskSyncAPI {
*/ */
static final class NotRegisteredException extends IllegalStateException { static final class NotRegisteredException extends IllegalStateException {
private static final String MESSAGE = """ private static final String REASONS = """
Could not access the HuskSync API as it has not yet been registered. This could be because: This may be because:
1) HuskSync has failed to enable successfully 1) HuskSync has failed to enable successfully
2) Your plugin isn't set to load after HuskSync has 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?) (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. 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(@NotNull String reasons) {
super("Could not access the HuskSync API as it has not yet been registered. %s".formatted(reasons));
}
NotRegisteredException() { NotRegisteredException() {
super(MESSAGE); this(REASONS);
} }
} }

View File

@@ -1,84 +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.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

@@ -23,18 +23,21 @@ import de.themoep.minedown.adventure.MineDown;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
import net.william278.husksync.data.Data; import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot; import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.redis.RedisKeyType;
import net.william278.husksync.redis.RedisManager;
import net.william278.husksync.user.OnlineUser; import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User; import net.william278.husksync.user.User;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public class EnderChestCommand extends ItemsCommand { public class EnderChestCommand extends ItemsCommand {
public EnderChestCommand(@NotNull HuskSync plugin) { public EnderChestCommand(@NotNull HuskSync plugin) {
super(plugin, List.of("enderchest", "echest", "openechest")); super("enderchest", List.of("echest", "openechest"), plugin);
} }
@Override @Override
@@ -49,7 +52,8 @@ public class EnderChestCommand extends ItemsCommand {
// Display opening message // Display opening message
plugin.getLocales().getLocale("ender_chest_viewer_opened", user.getUsername(), plugin.getLocales().getLocale("ender_chest_viewer_opened", user.getUsername(),
snapshot.getTimestamp().format(DateTimeFormatter.ofPattern("dd/MM/yyyy, HH:mm"))) snapshot.getTimestamp().format(DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)))
.ifPresent(viewer::sendMessage); .ifPresent(viewer::sendMessage);
// Show GUI // Show GUI
@@ -70,8 +74,8 @@ public class EnderChestCommand extends ItemsCommand {
// Creates a new snapshot with the updated enderChest // Creates a new snapshot with the updated enderChest
@SuppressWarnings("DuplicatedCode") @SuppressWarnings("DuplicatedCode")
private void updateItems(@NotNull OnlineUser viewer, @NotNull Data.Items.Items items, @NotNull User user) { private void updateItems(@NotNull OnlineUser viewer, @NotNull Data.Items.Items items, @NotNull User holder) {
final Optional<DataSnapshot.Packed> latestData = plugin.getDatabase().getLatestSnapshot(user); final Optional<DataSnapshot.Packed> latestData = plugin.getDatabase().getLatestSnapshot(holder);
if (latestData.isEmpty()) { if (latestData.isEmpty()) {
plugin.getLocales().getLocale("error_no_data_to_display") plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(viewer::sendMessage); .ifPresent(viewer::sendMessage);
@@ -81,12 +85,19 @@ public class EnderChestCommand extends ItemsCommand {
// Create and pack the snapshot with the updated enderChest // Create and pack the snapshot with the updated enderChest
final DataSnapshot.Packed snapshot = latestData.get().copy(); final DataSnapshot.Packed snapshot = latestData.get().copy();
snapshot.edit(plugin, (data) -> { 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)); data.getEnderChest().ifPresent(enderChest -> enderChest.setContents(items));
data.setSaveCause(DataSnapshot.SaveCause.ENDERCHEST_COMMAND);
data.setPinned(
plugin.getSettings().getSynchronization().doAutoPin(DataSnapshot.SaveCause.ENDERCHEST_COMMAND)
);
});
// Save data
final RedisManager redis = plugin.getRedisManager();
plugin.getDataSyncer().saveData(holder, snapshot, (user, data) -> {
redis.getUserData(user).ifPresent(d -> redis.setUserData(user, snapshot, RedisKeyType.TTL_1_YEAR));
redis.sendUserDataUpdate(user, data);
}); });
plugin.getDatabase().addSnapshot(user, snapshot);
plugin.getRedisManager().sendUserDataUpdate(user, snapshot);
} }
} }

View File

@@ -19,38 +19,44 @@
package net.william278.husksync.command; package net.william278.husksync.command;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import de.themoep.minedown.adventure.MineDown; import de.themoep.minedown.adventure.MineDown;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.JoinConfiguration;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor; import net.kyori.adventure.text.format.TextColor;
import net.william278.desertwell.about.AboutMenu; import net.william278.desertwell.about.AboutMenu;
import net.william278.desertwell.util.UpdateChecker; import net.william278.desertwell.util.UpdateChecker;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.database.Database;
import net.william278.husksync.migrator.Migrator; import net.william278.husksync.migrator.Migrator;
import net.william278.husksync.user.CommandUser; import net.william278.husksync.user.CommandUser;
import net.william278.husksync.user.OnlineUser; import net.william278.husksync.util.LegacyConverter;
import net.william278.uniform.BaseCommand;
import net.william278.uniform.CommandProvider;
import net.william278.uniform.Permission;
import net.william278.uniform.element.ArgumentElement;
import org.apache.commons.text.WordUtils;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*; import java.time.OffsetDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.function.Function;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class HuskSyncCommand extends Command implements TabProvider { public class HuskSyncCommand extends PluginCommand {
private static final Map<String, Boolean> SUB_COMMANDS = Map.of(
"about", false,
"reload", true,
"migrate", true,
"update", true
);
private final UpdateChecker updateChecker; private final UpdateChecker updateChecker;
private final AboutMenu aboutMenu; private final AboutMenu aboutMenu;
public HuskSyncCommand(@NotNull HuskSync plugin) { public HuskSyncCommand(@NotNull HuskSync plugin) {
super("husksync", List.of(), "[" + String.join("|", SUB_COMMANDS.keySet()) + "]", plugin); super("husksync", List.of(), Permission.Default.TRUE, ExecutionScope.ALL, plugin);
addAdditionalPermissions(SUB_COMMANDS);
this.updateChecker = plugin.getUpdateChecker(); this.updateChecker = plugin.getUpdateChecker();
this.aboutMenu = AboutMenu.builder() this.aboutMenu = AboutMenu.builder()
.title(Component.text("HuskSync")) .title(Component.text("HuskSync"))
@@ -60,7 +66,10 @@ public class HuskSyncCommand extends Command implements TabProvider {
AboutMenu.Credit.of("William278").description("Click to visit website").url("https://william278.net")) AboutMenu.Credit.of("William278").description("Click to visit website").url("https://william278.net"))
.credits("Contributors", .credits("Contributors",
AboutMenu.Credit.of("HarvelsX").description("Code"), AboutMenu.Credit.of("HarvelsX").description("Code"),
AboutMenu.Credit.of("HookWoods").description("Code")) AboutMenu.Credit.of("HookWoods").description("Code"),
AboutMenu.Credit.of("Preva1l").description("Code"),
AboutMenu.Credit.of("hanbings").description("Code (Fabric porting)"),
AboutMenu.Credit.of("Stampede2011").description("Code (Fabric mixins)"))
.credits("Translators", .credits("Translators",
AboutMenu.Credit.of("Namiu").description("Japanese (ja-jp)"), AboutMenu.Credit.of("Namiu").description("Japanese (ja-jp)"),
AboutMenu.Credit.of("anchelthe").description("Spanish (es-es)"), AboutMenu.Credit.of("anchelthe").description("Spanish (es-es)"),
@@ -72,7 +81,11 @@ public class HuskSyncCommand extends Command implements TabProvider {
AboutMenu.Credit.of("Ghost-chu").description("Simplified Chinese (zh-cn)"), AboutMenu.Credit.of("Ghost-chu").description("Simplified Chinese (zh-cn)"),
AboutMenu.Credit.of("DJelly4K").description("Simplified Chinese (zh-cn)"), AboutMenu.Credit.of("DJelly4K").description("Simplified Chinese (zh-cn)"),
AboutMenu.Credit.of("Thourgard").description("Ukrainian (uk-ua)"), AboutMenu.Credit.of("Thourgard").description("Ukrainian (uk-ua)"),
AboutMenu.Credit.of("xF3d3").description("Italian (it-it)")) AboutMenu.Credit.of("xF3d3").description("Italian (it-it)"),
AboutMenu.Credit.of("cada3141").description("Korean (ko-kr)"),
AboutMenu.Credit.of("Wirayuda5620").description("Indonesian (id-id)"),
AboutMenu.Credit.of("WinTone01").description("Turkish (tr-tr)"),
AboutMenu.Credit.of("IbanEtchep").description("French (fr-fr)"))
.buttons( .buttons(
AboutMenu.Link.of("https://william278.net/docs/husksync").text("Documentation").icon(""), AboutMenu.Link.of("https://william278.net/docs/husksync").text("Documentation").icon(""),
AboutMenu.Link.of("https://github.com/WiIIiam278/HuskSync/issues").text("Issues").icon("").color(TextColor.color(0xff9f0f)), AboutMenu.Link.of("https://github.com/WiIIiam278/HuskSync/issues").text("Issues").icon("").color(TextColor.color(0xff9f0f)),
@@ -81,104 +94,226 @@ public class HuskSyncCommand extends Command implements TabProvider {
} }
@Override @Override
public void execute(@NotNull CommandUser executor, @NotNull String[] args) { public void provide(@NotNull BaseCommand<?> command) {
final String subCommand = parseStringArg(args, 0).orElse("about").toLowerCase(Locale.ENGLISH); command.setDefaultExecutor((ctx) -> about(command, ctx));
if (SUB_COMMANDS.containsKey(subCommand) && !executor.hasPermission(getPermission(subCommand))) { command.addSubCommand("about", (sub) -> sub.setDefaultExecutor((ctx) -> about(command, ctx)));
plugin.getLocales().getLocale("error_no_permission") command.addSubCommand("status", needsOp("status"), status());
.ifPresent(executor::sendMessage); command.addSubCommand("reload", needsOp("reload"), reload());
return; command.addSubCommand("update", needsOp("update"), update());
} command.addSubCommand("forceupgrade", forceUpgrade());
command.addSubCommand("migrate", migrate());
switch (subCommand) {
case "about" -> executor.sendMessage(aboutMenu.toComponent());
case "reload" -> {
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 (executor instanceof OnlineUser) {
plugin.getLocales().getLocale("error_console_command_only")
.ifPresent(executor::sendMessage);
return;
}
this.handleMigrationCommand(args);
}
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);
}
} }
// Handle a migration console command input private void about(@NotNull BaseCommand<?> c, @NotNull CommandContext<?> ctx) {
private void handleMigrationCommand(@NotNull String[] args) { user(c, ctx).getAudience().sendMessage(aboutMenu.toComponent());
if (args.length < 2) { }
plugin.log(Level.INFO,
"Please choose a migrator, then run \"husksync migrate <migrator>\"");
this.logMigratorList();
return;
}
final Optional<Migrator> selectedMigrator = plugin.getAvailableMigrators().stream() @NotNull
.filter(available -> available.getIdentifier().equalsIgnoreCase(args[1])) private CommandProvider status() {
.findFirst(); return (sub) -> sub.setDefaultExecutor((ctx) -> {
selectedMigrator.ifPresentOrElse(migrator -> { final CommandUser user = user(sub, ctx);
if (args.length < 3) { plugin.getLocales().getLocale("system_status_header").ifPresent(user::sendMessage);
plugin.log(Level.INFO, migrator.getHelpMenu()); user.sendMessage(Component.join(
JoinConfiguration.newlines(),
Arrays.stream(StatusLine.values()).map(s -> s.get(plugin)).toList()
));
});
}
@NotNull
private CommandProvider reload() {
return (sub) -> sub.setDefaultExecutor((ctx) -> {
final CommandUser user = user(sub, ctx);
try {
plugin.loadSettings();
plugin.loadLocales();
plugin.loadServer();
plugin.getLocales().getLocale("reload_complete").ifPresent(user::sendMessage);
} catch (Throwable e) {
user.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);
}
});
}
@NotNull
private CommandProvider update() {
return (sub) -> sub.setDefaultExecutor((ctx) -> updateChecker.check().thenAccept(checked -> {
final CommandUser user = user(sub, ctx);
if (checked.isUpToDate()) {
plugin.getLocales().getLocale("up_to_date", plugin.getPluginVersion().toString())
.ifPresent(user::sendMessage);
return; return;
} }
switch (args[2]) { plugin.getLocales().getLocale("update_available", checked.getLatestVersion().toString(),
case "start" -> migrator.start().thenAccept(succeeded -> { plugin.getPluginVersion().toString()).ifPresent(user::sendMessage);
}));
}
@NotNull
private CommandProvider migrate() {
return (sub) -> {
sub.setCondition((ctx) -> sub.getUser(ctx).isConsole());
sub.setDefaultExecutor((ctx) -> {
plugin.log(Level.INFO, "Please choose a migrator, then run \"husksync migrate start <migrator>\"");
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"))
));
});
sub.addSubCommand("help", (help) -> help.addSyntax((cmd) -> {
final Migrator migrator = cmd.getArgument("migrator", Migrator.class);
plugin.log(Level.INFO, migrator.getHelpMenu());
}, migrator()));
sub.addSubCommand("start", (start) -> start.addSyntax((cmd) -> {
final Migrator migrator = cmd.getArgument("migrator", Migrator.class);
migrator.start().thenAccept(succeeded -> {
if (succeeded) { if (succeeded) {
plugin.log(Level.INFO, "Migration completed successfully!"); plugin.log(Level.INFO, "Migration completed successfully!");
} else { } else {
plugin.log(Level.WARNING, "Migration failed!"); plugin.log(Level.WARNING, "Migration failed!");
} }
}); });
case "set" -> migrator.handleConfigurationCommand(Arrays.copyOfRange(args, 3, args.length)); }, migrator()));
default -> plugin.log(Level.INFO, String.format( sub.addSubCommand("set", (set) -> set.addSyntax((cmd) -> {
"Invalid syntax. Console usage: \"husksync migrate %s <start/set>", args[1] final Migrator migrator = cmd.getArgument("migrator", Migrator.class);
)); final String[] args = cmd.getArgument("args", String.class).split(" ");
} migrator.handleConfigurationCommand(args);
}, () -> { }, migrator(), BaseCommand.greedyString("args")));
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;
}; };
} }
@NotNull
private CommandProvider forceUpgrade() {
return (sub) -> {
sub.setCondition((ctx) -> sub.getUser(ctx).isConsole());
sub.setDefaultExecutor((ctx) -> {
final LegacyConverter converter = plugin.getLegacyConverter().orElse(null);
if (converter == null) {
return;
}
plugin.runAsync(() -> {
final Database database = plugin.getDatabase();
plugin.log(Level.INFO, "Beginning forced legacy data upgrade for all users...");
database.getAllUsers().forEach(user -> database.getLatestSnapshot(user).ifPresent(snapshot -> {
final DataSnapshot.Packed upgraded = converter.convert(
snapshot.asBytes(plugin),
UUID.randomUUID(),
OffsetDateTime.now()
);
upgraded.setSaveCause(DataSnapshot.SaveCause.CONVERTED_FROM_V2);
plugin.getDatabase().addSnapshot(user, upgraded);
plugin.getRedisManager().clearUserData(user);
}));
plugin.log(Level.INFO, "Legacy data upgrade complete!");
});
});
};
}
@NotNull
private <S> ArgumentElement<S, Migrator> migrator() {
return new ArgumentElement<>("migrator", reader -> {
final String id = reader.readString();
final Migrator migrator = plugin.getAvailableMigrators().stream()
.filter(m -> m.getIdentifier().equalsIgnoreCase(id)).findFirst().orElse(null);
if (migrator == null) {
throw CommandSyntaxException.BUILT_IN_EXCEPTIONS.dispatcherUnknownArgument().createWithContext(reader);
}
return migrator;
}, (context, builder) -> {
for (Migrator material : plugin.getAvailableMigrators()) {
builder.suggest(material.getIdentifier());
}
return builder.buildFuture();
});
}
private enum StatusLine {
PLUGIN_VERSION(plugin -> Component.text("v" + plugin.getPluginVersion().toStringWithoutMetadata())
.appendSpace().append(plugin.getPluginVersion().getMetadata().isBlank() ? Component.empty()
: Component.text("(build " + plugin.getPluginVersion().getMetadata() + ")"))),
SERVER_VERSION(plugin -> Component.text(plugin.getServerVersion())),
LANGUAGE(plugin -> Component.text(plugin.getSettings().getLanguage())),
MINECRAFT_VERSION(plugin -> Component.text(plugin.getMinecraftVersion().toString())),
JAVA_VERSION(plugin -> Component.text(System.getProperty("java.version"))),
JAVA_VENDOR(plugin -> Component.text(System.getProperty("java.vendor"))),
SERVER_NAME(plugin -> Component.text(plugin.getServerName())),
CLUSTER_ID(plugin -> Component.text(plugin.getSettings().getClusterId().isBlank() ? "None" : plugin.getSettings().getClusterId())),
SYNC_MODE(plugin -> Component.text(WordUtils.capitalizeFully(
plugin.getSettings().getSynchronization().getMode().toString()
))),
DELAY_LATENCY(plugin -> Component.text(
plugin.getSettings().getSynchronization().getNetworkLatencyMilliseconds() + "ms"
)),
DATABASE_TYPE(plugin ->
Component.text(plugin.getSettings().getDatabase().getType().getDisplayName() +
(plugin.getSettings().getDatabase().getType() == Database.Type.MONGO ?
(plugin.getSettings().getDatabase().getMongoSettings().isUsingAtlas() ? " Atlas" : "") : ""))
),
IS_DATABASE_LOCAL(plugin -> getLocalhostBoolean(plugin.getSettings().getDatabase().getCredentials().getHost())),
USING_REDIS_SENTINEL(plugin -> getBoolean(
!plugin.getSettings().getRedis().getSentinel().getMaster().isBlank()
)),
USING_REDIS_PASSWORD(plugin -> getBoolean(
!plugin.getSettings().getRedis().getCredentials().getPassword().isBlank()
)),
REDIS_USING_SSL(plugin -> getBoolean(
plugin.getSettings().getRedis().getCredentials().isUseSsl()
)),
IS_REDIS_LOCAL(plugin -> getLocalhostBoolean(
plugin.getSettings().getRedis().getCredentials().getHost()
)),
DATA_TYPES(plugin -> Component.join(
JoinConfiguration.commas(true),
plugin.getRegisteredDataTypes().stream().map(i -> Component.textOfChildren(Component.text(i.toString())
.appendSpace().append(Component.text(i.isEnabled() ? '✔' : '❌')))
.color(i.isEnabled() ? NamedTextColor.GREEN : NamedTextColor.RED)
.hoverEvent(HoverEvent.showText(
Component.text(i.isEnabled() ? "Enabled" : "Disabled")
.append(Component.newline())
.append(Component.text("Dependencies: %s".formatted(i.getDependencies()
.isEmpty() ? "(None)" : i.getDependencies().stream()
.map(d -> "%s (%s)".formatted(
d.getKey().value(), d.isRequired() ? "Required" : "Optional"
)).collect(Collectors.joining(", ")))
).color(NamedTextColor.GRAY))
))).toList()
));
private final Function<HuskSync, Component> supplier;
StatusLine(@NotNull Function<HuskSync, Component> supplier) {
this.supplier = supplier;
}
@NotNull
private Component get(@NotNull HuskSync plugin) {
return Component
.text("").appendSpace()
.append(Component.text(
WordUtils.capitalizeFully(name().replaceAll("_", " ")),
TextColor.color(0x848484)
))
.append(Component.text(':')).append(Component.space().color(NamedTextColor.WHITE))
.append(supplier.apply(plugin));
}
@NotNull
private static Component getBoolean(boolean value) {
return Component.text(value ? "Yes" : "No", value ? NamedTextColor.GREEN : NamedTextColor.RED);
}
@NotNull
private static Component getLocalhostBoolean(@NotNull String value) {
return getBoolean(value.equals("127.0.0.1") || value.equals("0.0.0.0")
|| value.equals("localhost") || value.equals("::1"));
}
}
} }

View File

@@ -23,18 +23,21 @@ import de.themoep.minedown.adventure.MineDown;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
import net.william278.husksync.data.Data; import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot; import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.redis.RedisKeyType;
import net.william278.husksync.redis.RedisManager;
import net.william278.husksync.user.OnlineUser; import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User; import net.william278.husksync.user.User;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public class InventoryCommand extends ItemsCommand { public class InventoryCommand extends ItemsCommand {
public InventoryCommand(@NotNull HuskSync plugin) { public InventoryCommand(@NotNull HuskSync plugin) {
super(plugin, List.of("inventory", "invsee", "openinv")); super("inventory", List.of("invsee", "openinv"), plugin);
} }
@Override @Override
@@ -42,6 +45,7 @@ public class InventoryCommand extends ItemsCommand {
@NotNull User user, boolean allowEdit) { @NotNull User user, boolean allowEdit) {
final Optional<Data.Items.Inventory> optionalInventory = snapshot.getInventory(); final Optional<Data.Items.Inventory> optionalInventory = snapshot.getInventory();
if (optionalInventory.isEmpty()) { if (optionalInventory.isEmpty()) {
viewer.sendMessage(new MineDown("what the FUCK is happening"));
plugin.getLocales().getLocale("error_no_data_to_display") plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(viewer::sendMessage); .ifPresent(viewer::sendMessage);
return; return;
@@ -49,7 +53,8 @@ public class InventoryCommand extends ItemsCommand {
// Display opening message // Display opening message
plugin.getLocales().getLocale("inventory_viewer_opened", user.getUsername(), plugin.getLocales().getLocale("inventory_viewer_opened", user.getUsername(),
snapshot.getTimestamp().format(DateTimeFormatter.ofPattern("dd/MM/yyyy, HH:mm"))) snapshot.getTimestamp().format(DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)))
.ifPresent(viewer::sendMessage); .ifPresent(viewer::sendMessage);
// Show GUI // Show GUI
@@ -70,8 +75,8 @@ public class InventoryCommand extends ItemsCommand {
// Creates a new snapshot with the updated inventory // Creates a new snapshot with the updated inventory
@SuppressWarnings("DuplicatedCode") @SuppressWarnings("DuplicatedCode")
private void updateItems(@NotNull OnlineUser viewer, @NotNull Data.Items.Items items, @NotNull User user) { private void updateItems(@NotNull OnlineUser viewer, @NotNull Data.Items.Items items, @NotNull User holder) {
final Optional<DataSnapshot.Packed> latestData = plugin.getDatabase().getLatestSnapshot(user); final Optional<DataSnapshot.Packed> latestData = plugin.getDatabase().getLatestSnapshot(holder);
if (latestData.isEmpty()) { if (latestData.isEmpty()) {
plugin.getLocales().getLocale("error_no_data_to_display") plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(viewer::sendMessage); .ifPresent(viewer::sendMessage);
@@ -81,12 +86,19 @@ public class InventoryCommand extends ItemsCommand {
// Create and pack the snapshot with the updated inventory // Create and pack the snapshot with the updated inventory
final DataSnapshot.Packed snapshot = latestData.get().copy(); final DataSnapshot.Packed snapshot = latestData.get().copy();
snapshot.edit(plugin, (data) -> { 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)); data.getInventory().ifPresent(inventory -> inventory.setContents(items));
data.setSaveCause(DataSnapshot.SaveCause.INVENTORY_COMMAND);
data.setPinned(
plugin.getSettings().getSynchronization().doAutoPin(DataSnapshot.SaveCause.INVENTORY_COMMAND)
);
});
// Save data
final RedisManager redis = plugin.getRedisManager();
plugin.getDataSyncer().saveData(holder, snapshot, (user, data) -> {
redis.getUserData(user).ifPresent(d -> redis.setUserData(user, snapshot, RedisKeyType.TTL_1_YEAR));
redis.sendUserDataUpdate(user, data);
}); });
plugin.getDatabase().addSnapshot(user, snapshot);
plugin.getRedisManager().sendUserDataUpdate(user, snapshot);
} }
} }

View File

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

@@ -1,105 +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 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

@@ -0,0 +1,129 @@
/*
* 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 com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.william278.husksync.HuskSync;
import net.william278.husksync.user.CommandUser;
import net.william278.husksync.user.User;
import net.william278.uniform.BaseCommand;
import net.william278.uniform.Command;
import net.william278.uniform.Permission;
import net.william278.uniform.element.ArgumentElement;
import org.jetbrains.annotations.NotNull;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.function.Function;
public abstract class PluginCommand extends Command {
protected final HuskSync plugin;
protected PluginCommand(@NotNull String name, @NotNull List<String> aliases, @NotNull Permission.Default defPerm,
@NotNull ExecutionScope scope, @NotNull HuskSync plugin) {
super(name, aliases, getDescription(plugin, name), new Permission(createPermission(name), defPerm), scope);
this.plugin = plugin;
}
private static String getDescription(@NotNull HuskSync plugin, @NotNull String name) {
return plugin.getLocales().getRawLocale("%s_command_description".formatted(name)).orElse("");
}
@NotNull
private static String createPermission(@NotNull String name, @NotNull String... sub) {
return "husksync.command." + name + (sub.length > 0 ? "." + String.join(".", sub) : "");
}
@NotNull
protected String getPermission(@NotNull String... sub) {
return createPermission(this.getName(), sub);
}
@NotNull
@SuppressWarnings("rawtypes")
protected CommandUser user(@NotNull BaseCommand base, @NotNull CommandContext context) {
return adapt(base.getUser(context.getSource()));
}
@NotNull
protected Permission needsOp(@NotNull String... nodes) {
return new Permission(getPermission(nodes), Permission.Default.IF_OP);
}
@NotNull
protected CommandUser adapt(net.william278.uniform.CommandUser user) {
return user.getUuid() == null ? plugin.getConsole() : plugin.getOnlineUser(user.getUuid()).orElseThrow();
}
@NotNull
protected <S> ArgumentElement<S, User> user(@NotNull String name) {
return new ArgumentElement<>(name, reader -> {
final String username = reader.readString();
return plugin.getDatabase().getUserByName(username).orElseThrow(
() -> CommandSyntaxException.BUILT_IN_EXCEPTIONS.dispatcherUnknownArgument().createWithContext(reader)
);
}, (context, builder) -> {
plugin.getOnlineUsers().forEach(u -> builder.suggest(u.getUsername()));
return builder.buildFuture();
});
}
@NotNull
protected <S> ArgumentElement<S, UUID> uuid(@NotNull String name) {
return new ArgumentElement<>(name, reader -> {
try {
return UUID.fromString(reader.readString());
} catch (IllegalArgumentException e) {
throw CommandSyntaxException.BUILT_IN_EXCEPTIONS.dispatcherUnknownArgument().createWithContext(reader);
}
}, (context, builder) -> builder.buildFuture());
}
public enum Type {
HUSKSYNC_COMMAND(HuskSyncCommand::new),
USERDATA_COMMAND(UserDataCommand::new),
INVENTORY_COMMAND(InventoryCommand::new),
ENDER_CHEST_COMMAND(EnderChestCommand::new);
public final Function<HuskSync, PluginCommand> commandSupplier;
Type(@NotNull Function<HuskSync, PluginCommand> supplier) {
this.commandSupplier = supplier;
}
@NotNull
public PluginCommand supply(@NotNull HuskSync plugin) {
return commandSupplier.apply(plugin);
}
@NotNull
public static PluginCommand[] create(@NotNull HuskSync plugin) {
return Arrays.stream(values()).map(type -> type.supply(plugin))
.filter(command -> !plugin.getSettings().isCommandDisabled(command))
.toArray(PluginCommand[]::new);
}
}
}

View File

@@ -1,50 +0,0 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.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

@@ -19,217 +19,268 @@
package net.william278.husksync.command; package net.william278.husksync.command;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSnapshot; import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.redis.RedisKeyType;
import net.william278.husksync.redis.RedisManager;
import net.william278.husksync.user.CommandUser; import net.william278.husksync.user.CommandUser;
import net.william278.husksync.user.User; import net.william278.husksync.user.User;
import net.william278.husksync.util.DataDumper; import net.william278.husksync.util.DataDumper;
import net.william278.husksync.util.DataSnapshotList; import net.william278.husksync.util.DataSnapshotList;
import net.william278.husksync.util.DataSnapshotOverview; import net.william278.husksync.util.DataSnapshotOverview;
import net.william278.uniform.BaseCommand;
import net.william278.uniform.CommandProvider;
import net.william278.uniform.Permission;
import net.william278.uniform.element.ArgumentElement;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*; import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID;
import java.util.logging.Level; import java.util.logging.Level;
public class UserDataCommand extends Command implements TabProvider { public class UserDataCommand extends PluginCommand {
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 plugin) { public UserDataCommand(@NotNull HuskSync plugin) {
super("userdata", List.of("playerdata"), String.format( super("userdata", List.of("playerdata"), Permission.Default.IF_OP, ExecutionScope.ALL, plugin);
"<%s> [username] [version_uuid]", String.join("/", SUB_COMMANDS.keySet())
), plugin);
setOperatorCommand(true);
addAdditionalPermissions(SUB_COMMANDS);
} }
@Override @Override
public void execute(@NotNull CommandUser executor, @NotNull String[] args) { public void provide(@NotNull BaseCommand<?> command) {
final String subCommand = parseStringArg(args, 0).orElse("view").toLowerCase(Locale.ENGLISH); command.addSubCommand("view", needsOp("view"), view());
final Optional<User> optionalUser = parseStringArg(args, 1) command.addSubCommand("list", needsOp("list"), list());
.flatMap(name -> plugin.getDatabase().getUserByName(name)) command.addSubCommand("delete", needsOp("delete"), delete());
.or(() -> parseStringArg(args, 0).flatMap(name -> plugin.getDatabase().getUserByName(name))) command.addSubCommand("restore", needsOp("restore"), restore());
.or(() -> args.length < 2 && executor instanceof User userExecutor command.addSubCommand("pin", needsOp("pin"), pin());
? Optional.of(userExecutor) : Optional.empty()); command.addSubCommand("dump", needsOp("dump"), dump());
final Optional<UUID> optionalUuid = parseUUIDArg(args, 2).or(() -> parseUUIDArg(args, 1)); }
if (optionalUser.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_player") // Show the latest snapshot
private void viewLatestSnapshot(@NotNull CommandUser executor, @NotNull User user) {
plugin.getDatabase().getLatestSnapshot(user).ifPresentOrElse(
data -> {
if (data.isInvalid()) {
plugin.getLocales().getLocale("error_invalid_data", data.getInvalidReason(plugin))
.ifPresent(executor::sendMessage);
return;
}
DataSnapshotOverview.of(data.unpack(plugin), data.getFileSize(plugin), user, plugin)
.show(executor);
},
() -> plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(executor::sendMessage)
);
}
// Show the specified snapshot
private void viewSnapshot(@NotNull CommandUser executor, @NotNull User user, @NotNull UUID version) {
plugin.getDatabase().getSnapshot(user, version).ifPresentOrElse(
data -> {
if (data.isInvalid()) {
plugin.getLocales().getLocale("error_invalid_data", data.getInvalidReason(plugin))
.ifPresent(executor::sendMessage);
return;
}
DataSnapshotOverview.of(data.unpack(plugin), data.getFileSize(plugin), user, plugin)
.show(executor);
},
() -> plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(executor::sendMessage)
);
}
// View a list of snapshots
private void listSnapshots(@NotNull CommandUser executor, @NotNull User user, int page) {
final List<DataSnapshot.Packed> dataList = plugin.getDatabase().getAllSnapshots(user);
if (dataList.isEmpty()) {
plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(executor::sendMessage);
return;
}
DataSnapshotList.create(dataList, user, plugin).displayPage(executor, page);
}
// Delete a snapshot
private void deleteSnapshot(@NotNull CommandUser executor, @NotNull User user, @NotNull UUID version) {
if (!plugin.getDatabase().deleteSnapshot(user, version)) {
plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(executor::sendMessage);
return;
}
plugin.getRedisManager().clearUserData(user);
plugin.getLocales().getLocale("data_deleted",
version.toString().split("-")[0],
version.toString(),
user.getUsername(),
user.getUuid().toString())
.ifPresent(executor::sendMessage);
}
// Restore a snapshot
private void restoreSnapshot(@NotNull CommandUser executor, @NotNull User user, @NotNull UUID version) {
final Optional<DataSnapshot.Packed> optionalData = plugin.getDatabase().getSnapshot(user, version);
if (optionalData.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(executor::sendMessage); .ifPresent(executor::sendMessage);
return; return;
} }
final User user = optionalUser.get(); // Restore users with a minimum of one health (prevent restoring players with <= 0 health)
switch (subCommand) { final DataSnapshot.Packed data = optionalData.get().copy();
case "view" -> optionalUuid.ifPresentOrElse( if (data.isInvalid()) {
// Show the specified snapshot plugin.getLocales().getLocale("error_invalid_data", data.getInvalidReason(plugin))
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" -> {
// 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;
}
// 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 (optionalUuid.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata delete <username> <version_uuid>")
.ifPresent(executor::sendMessage);
return;
}
// Delete user data by specified UUID
final UUID version = optionalUuid.get();
if (!plugin.getDatabase().deleteSnapshot(user, version)) {
plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(executor::sendMessage);
return;
}
plugin.getLocales().getLocale("data_deleted",
version.toString().split("-")[0],
version.toString(),
user.getUsername(),
user.getUuid().toString())
.ifPresent(executor::sendMessage);
}
case "restore" -> {
if (optionalUuid.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata restore <username> <version_uuid>")
.ifPresent(executor::sendMessage);
return;
}
// 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;
}
// 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 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 (optionalUuid.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata pin <username> <version_uuid>")
.ifPresent(executor::sendMessage);
return;
}
// 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 (optionalUuid.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata dump <username> <version_uuid>")
.ifPresent(executor::sendMessage);
return;
}
// 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 {
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); .ifPresent(executor::sendMessage);
return;
}
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().getSynchronization().doAutoPin(DataSnapshot.SaveCause.BACKUP_RESTORE)
);
}));
// Save data
final RedisManager redis = plugin.getRedisManager();
plugin.getDataSyncer().saveData(user, data, (u, s) -> {
redis.getUserData(u).ifPresent(d -> redis.setUserData(u, s, RedisKeyType.TTL_1_YEAR));
redis.sendUserDataUpdate(u, s);
plugin.getLocales().getLocale("data_restored", u.getUsername(), u.getUuid().toString(),
s.getShortId(), s.getId().toString()).ifPresent(executor::sendMessage);
});
}
// Pin a snapshot
private void pinSnapshot(@NotNull CommandUser executor, @NotNull User user, @NotNull UUID version) {
final Optional<DataSnapshot.Packed> optionalData = plugin.getDatabase().getSnapshot(user, version);
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);
}
// Dump a snapshot
private void dumpSnapshot(@NotNull CommandUser executor, @NotNull User user, @NotNull UUID version,
@NotNull DumpType type) {
final Optional<DataSnapshot.Packed> data = plugin.getDatabase().getSnapshot(user, version);
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 {
plugin.getLocales().getLocale("data_dumped", userData.getShortId(), user.getUsername(),
(type == DumpType.WEB ? dumper.toWeb() : dumper.toFile()))
.ifPresent(executor::sendMessage);
} catch (Throwable e) {
plugin.log(Level.SEVERE, "Failed to dump user data", e);
} }
} }
@Nullable @NotNull
@Override private CommandProvider view() {
public List<String> suggest(@NotNull CommandUser executor, @NotNull String[] args) { return (sub) -> {
return switch (args.length) { sub.addSyntax((ctx) -> {
case 0, 1 -> SUB_COMMANDS.keySet().stream().sorted().toList(); final User user = ctx.getArgument("username", User.class);
case 2 -> plugin.getOnlineUsers().stream().map(User::getUsername).toList(); viewLatestSnapshot(user(sub, ctx), user);
case 4 -> parseStringArg(args, 0) }, user("username"));
.map(arg -> arg.equalsIgnoreCase("dump") ? List.of("web", "file") : null) sub.addSyntax((ctx) -> {
.orElse(null); final User user = ctx.getArgument("username", User.class);
default -> null; final UUID version = ctx.getArgument("version", UUID.class);
viewSnapshot(user(sub, ctx), user, version);
}, user("username"), uuid("version"));
}; };
} }
@NotNull
private CommandProvider list() {
return (sub) -> {
sub.addSyntax((ctx) -> {
final User user = ctx.getArgument("username", User.class);
listSnapshots(user(sub, ctx), user, 1);
}, user("username"));
sub.addSyntax((ctx) -> {
final User user = ctx.getArgument("username", User.class);
final int page = ctx.getArgument("page", Integer.class);
listSnapshots(user(sub, ctx), user, page);
}, user("username"), BaseCommand.intNum("page", 1));
};
}
@NotNull
private CommandProvider delete() {
return (sub) -> sub.addSyntax((ctx) -> {
final User user = ctx.getArgument("username", User.class);
final UUID version = ctx.getArgument("version", UUID.class);
deleteSnapshot(user(sub, ctx), user, version);
}, user("username"), uuid("version"));
}
@NotNull
private CommandProvider restore() {
return (sub) -> sub.addSyntax((ctx) -> {
final User user = ctx.getArgument("username", User.class);
final UUID version = ctx.getArgument("version", UUID.class);
restoreSnapshot(user(sub, ctx), user, version);
}, user("username"), uuid("version"));
}
@NotNull
private CommandProvider pin() {
return (sub) -> sub.addSyntax((ctx) -> {
final User user = ctx.getArgument("username", User.class);
final UUID version = ctx.getArgument("version", UUID.class);
pinSnapshot(user(sub, ctx), user, version);
}, user("username"), uuid("version"));
}
@NotNull
private CommandProvider dump() {
return (sub) -> sub.addSyntax((ctx) -> {
final User user = ctx.getArgument("username", User.class);
final UUID version = ctx.getArgument("version", UUID.class);
final DumpType type = ctx.getArgument("type", DumpType.class);
dumpSnapshot(user(sub, ctx), user, version, type);
}, user("username"), uuid("version"), dumpType());
}
private <S> ArgumentElement<S, DumpType> dumpType() {
return new ArgumentElement<>("type", reader -> {
final String type = reader.readString();
return switch (type.toLowerCase(Locale.ENGLISH)) {
case "web" -> DumpType.WEB;
case "file" -> DumpType.FILE;
default -> throw CommandSyntaxException.BUILT_IN_EXCEPTIONS
.dispatcherUnknownArgument().createWithContext(reader);
};
}, (context, builder) -> {
builder.suggest("web");
builder.suggest("file");
return builder.buildFuture();
});
}
enum DumpType {
WEB,
FILE
}
} }

View File

@@ -0,0 +1,164 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.config;
import de.exlll.configlib.NameFormatters;
import de.exlll.configlib.YamlConfigurationProperties;
import de.exlll.configlib.YamlConfigurationStore;
import de.exlll.configlib.YamlConfigurations;
import net.william278.husksync.HuskSync;
import org.jetbrains.annotations.NotNull;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.logging.Level;
/**
* Interface for getting and setting data from plugin configuration files
*
* @since 1.0
*/
public interface ConfigProvider {
@NotNull
YamlConfigurationProperties.Builder<?> YAML_CONFIGURATION_PROPERTIES = YamlConfigurationProperties.newBuilder()
.charset(StandardCharsets.UTF_8)
.setNameFormatter(NameFormatters.LOWER_UNDERSCORE);
/**
* Get the plugin settings, read from the config file
*
* @return the plugin settings
* @since 1.0
*/
@NotNull
Settings getSettings();
/**
* Set the plugin settings
*
* @param settings The settings to set
* @since 1.0
*/
void setSettings(@NotNull Settings settings);
/**
* Load the plugin settings from the config file
*
* @since 1.0
*/
default void loadSettings() {
setSettings(YamlConfigurations.update(
getConfigDirectory().resolve("config.yml"),
Settings.class,
YAML_CONFIGURATION_PROPERTIES.header(Settings.CONFIG_HEADER).build()
));
}
/**
* Get the locales for the plugin
*
* @return the locales for the plugin
* @since 1.0
*/
@NotNull
Locales getLocales();
/**
* Set the locales for the plugin
*
* @param locales The locales to set
* @since 1.0
*/
void setLocales(@NotNull Locales locales);
/**
* Load the locales from the config file
*
* @since 1.0
*/
default void loadLocales() {
final YamlConfigurationStore<Locales> store = new YamlConfigurationStore<>(
Locales.class, YAML_CONFIGURATION_PROPERTIES.header(Locales.CONFIG_HEADER).build()
);
// Read existing locales if present
final Path path = getConfigDirectory().resolve(String.format("messages-%s.yml", getSettings().getLanguage()));
if (Files.exists(path)) {
setLocales(store.load(path));
return;
}
// Otherwise, save and read the default locales
try (InputStream input = getResource(String.format("locales/%s.yml", getSettings().getLanguage()))) {
final Locales locales = store.read(input);
store.save(locales, path);
setLocales(locales);
} catch (Throwable e) {
getPlugin().log(Level.SEVERE, "An error occurred loading the locales (invalid lang code?)", e);
}
}
@NotNull
String getServerName();
void setServerName(@NotNull Server server);
default void loadServer() {
setServerName(YamlConfigurations.update(
getConfigDirectory().resolve("server.yml"),
Server.class,
YAML_CONFIGURATION_PROPERTIES.header(Server.CONFIG_HEADER).build()
));
}
default void validateConfigFiles() {
// Validate server name is default
if (getServerName().equals("server")) {
getPlugin().log(Level.WARNING, "The server name set in ~/plugins/HuskSync/server.yml appears to" +
"be unchanged from the default (currently set to: \"server\"). Please check that this value has" +
"been updated to match the case-sensitive ID of this server in your proxy config file!");
}
}
/**
* Get a plugin resource
*
* @param name The name of the resource
* @return the resource, if found
* @since 1.0
*/
InputStream getResource(@NotNull String name);
/**
* Get the plugin config directory
*
* @return the plugin config directory
* @since 1.0
*/
@NotNull
Path getConfigDirectory();
@NotNull
HuskSync getPlugin();
}

View File

@@ -19,53 +19,60 @@
package net.william278.husksync.config; package net.william278.husksync.config;
import com.google.common.collect.Maps;
import de.exlll.configlib.Configuration;
import de.themoep.minedown.adventure.MineDown; import de.themoep.minedown.adventure.MineDown;
import net.william278.annotaml.YamlFile; import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import net.william278.paginedown.ListOptions; import net.william278.paginedown.ListOptions;
import org.apache.commons.text.StringEscapeUtils; import org.apache.commons.text.StringEscapeUtils;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
* Loaded locales used by the plugin to display styled messages * Plugin locale configuration
*
* @since 1.0
*/ */
@YamlFile(rootedMap = true, header = """ @SuppressWarnings("FieldMayBeFinal")
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @Configuration
┃ HuskSync Locales ┃ @NoArgsConstructor(access = AccessLevel.PRIVATE)
┃ Developed by William278 ┃
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
┣╸ See plugin about menu for international locale credits
┣╸ Formatted in MineDown: https://github.com/Phoenix616/MineDown
┗╸ Translate HuskSync: https://william278.net/docs/husksync/Translations""")
public class Locales { public class Locales {
/** static final String CONFIG_HEADER = """
* The raw set of locales loaded from yaml ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
*/ ┃ HuskSync - Locales
@NotNull ┃ Developed by William278 ┃
public Map<String, String> rawLocales = new HashMap<>(); ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
┣╸ See plugin about menu for international locale credits
┣╸ Formatted in MineDown: https://github.com/Phoenix616/MineDown
┗╸ Translate HuskSync: https://william278.net/docs/husksync/translations""";
protected static final String DEFAULT_LOCALE = "en-gb";
// The raw set of locales loaded from yaml
Map<String, String> locales = Maps.newTreeMap();
/** /**
* Returns a raw, unformatted locale loaded from the Locales file * Returns a raw, unformatted locale loaded from the locale file
* *
* @param localeId String identifier of the locale, corresponding to a key in the 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 * @return An {@link Optional} containing the locale corresponding to the id, if it exists
*/ */
public Optional<String> getRawLocale(@NotNull String localeId) { public Optional<String> getRawLocale(@NotNull String localeId) {
return Optional.ofNullable(rawLocales.get(localeId)).map(StringEscapeUtils::unescapeJava); return Optional.ofNullable(locales.get(localeId)).map(StringEscapeUtils::unescapeJava);
} }
/** /**
* Returns a raw, unformatted locale loaded from the Locales file, with replacements applied * Returns a raw, un-formatted locale loaded from the locales file, with replacements applied
* <p> * <p>
* Note that replacements will not be MineDown-escaped; use {@link #escapeMineDown(String)} to escape replacements * Note that replacements will not be MineDown-escaped; use {@link #escapeText(String)} to escape replacements
* *
* @param localeId String identifier of the locale, corresponding to a key in the file * @param localeId String identifier of the locale, corresponding to a key in the file
* @param replacements An ordered array of replacement strings to fill in placeholders with * @param replacements 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 * @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) { public Optional<String> getRawLocale(@NotNull String localeId, @NotNull String... replacements) {
@@ -73,34 +80,45 @@ 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 * @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 * @return An {@link Optional} containing the formatted locale corresponding to the id, if it exists
*/ */
public Optional<MineDown> getLocale(@NotNull String localeId) { public Optional<MineDown> getLocale(@NotNull String localeId) {
return getRawLocale(localeId).map(MineDown::new); return getRawLocale(localeId).map(this::format);
} }
/** /**
* 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> * <p>
* Note that replacements will be MineDown-escaped before application * Note that replacements will be MineDown-escaped before application
* *
* @param localeId String identifier of the locale, corresponding to a key in the file * @param localeId String identifier of the locale, corresponding to a key in the file
* @param replacements An ordered array of replacement strings to fill in placeholders with * @param replacements 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 * @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) { public Optional<MineDown> getLocale(@NotNull String localeId, @NotNull String... replacements) {
return getRawLocale(localeId, Arrays.stream(replacements).map(Locales::escapeMineDown) return getRawLocale(localeId, Arrays.stream(replacements).map(Locales::escapeText)
.toArray(String[]::new)).map(MineDown::new); .toArray(String[]::new)).map(this::format);
}
/**
* Returns a MineDown-formatted string
*
* @param text The text to format
* @return A {@link MineDown} object containing the formatted text
*/
@NotNull
public MineDown format(@NotNull String text) {
return new MineDown(text);
} }
/** /**
* Apply placeholder replacements to a raw locale * Apply placeholder replacements to a raw locale
* *
* @param rawLocale The raw, unparsed locale * @param rawLocale The raw, unparsed locale
* @param replacements An ordered array of replacement strings to fill in placeholders with * @param replacements Ordered array of replacement strings to fill in placeholders with
* @return the raw locale, with inserted placeholders * @return the raw locale, with inserted placeholders
*/ */
@NotNull @NotNull
@@ -116,15 +134,12 @@ public class Locales {
/** /**
* Escape a string from {@link MineDown} formatting for use in a MineDown-formatted locale * Escape a string from {@link MineDown} formatting for use in a MineDown-formatted locale
* <p>
* Although MineDown provides {@link MineDown#escape(String)}, that method fails to escape events
* properly when using the escaped string in a replacement, so this is used instead
* *
* @param string The string to escape * @param string The string to escape
* @return The escaped string * @return The escaped string
*/ */
@NotNull @NotNull
public static String escapeMineDown(@NotNull String string) { public static String escapeText(@NotNull String string) {
final StringBuilder value = new StringBuilder(); final StringBuilder value = new StringBuilder();
for (int i = 0; i < string.length(); ++i) { for (int i = 0; i < string.length(); ++i) {
char c = string.charAt(i); char c = string.charAt(i);
@@ -140,21 +155,6 @@ public class Locales {
return value.toString(); return value.toString();
} }
/**
* Truncates a String to a specified length, and appends an ellipsis if it is longer than the specified length
*
* @param string The string to truncate
* @param length The maximum length of the string
* @return The truncated string
*/
@NotNull
public static String truncate(@NotNull String string, int length) {
if (string.length() > length) {
return string.substring(0, length) + "";
}
return string;
}
/** /**
* Returns the base list options to use for a paginated chat list * Returns the base list options to use for a paginated chat list
* *
@@ -185,10 +185,6 @@ public class Locales {
.setSpaceBeforeFooter(false); .setSpaceBeforeFooter(false);
} }
@SuppressWarnings("unused")
public Locales() {
}
/** /**
* Determines the slot a system notification should be displayed in * Determines the slot a system notification should be displayed in
*/ */
@@ -197,14 +193,20 @@ public class Locales {
* Displays the notification in the action bar * Displays the notification in the action bar
*/ */
ACTION_BAR, ACTION_BAR,
/** /**
* Displays the notification in the chat * Displays the notification in the chat
*/ */
CHAT, CHAT,
/** /**
* Displays the notification in an Advancement Toast * Displays the notification in an Advancement Toast
*
* @deprecated No longer supported
*/ */
@Deprecated(since = "3.6.7")
TOAST, TOAST,
/** /**
* Does not display the notification * Does not display the notification
*/ */

View File

@@ -0,0 +1,70 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.config;
import de.exlll.configlib.Configuration;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.jetbrains.annotations.NotNull;
import java.nio.file.Path;
@Getter
@Configuration
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Server {
static final String CONFIG_HEADER = """
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ HuskSync - Server ID ┃
┃ Developed by William278 ┃
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
┣╸ This file should contain the ID of this server as defined in your proxy config.
┗╸ If you join it using /server alpha, then set it to 'alpha' (case-sensitive)""";
private String name = getDefault();
@NotNull
public static Server of(@NotNull String name) {
return new Server(name);
}
/**
* Find a sensible default name for the server name property
*/
@NotNull
private static String getDefault() {
final String serverFolder = System.getProperty("user.dir");
return serverFolder == null ? "server" : Path.of(serverFolder).getFileName().toString().trim();
}
@Override
public boolean equals(@NotNull Object other) {
// If the name of this server matches another, the servers are the same.
if (other instanceof Server server) {
return server.getName().equalsIgnoreCase(this.getName());
}
return super.equals(other);
}
}

View File

@@ -19,399 +19,330 @@
package net.william278.husksync.config; package net.william278.husksync.config;
import net.william278.annotaml.YamlComment; import com.google.common.collect.Lists;
import net.william278.annotaml.YamlFile; import de.exlll.configlib.Comment;
import net.william278.annotaml.YamlKey; import de.exlll.configlib.Configuration;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import net.william278.husksync.command.PluginCommand;
import net.william278.husksync.data.DataSnapshot; import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.data.Identifier; import net.william278.husksync.data.Identifier;
import net.william278.husksync.database.Database; import net.william278.husksync.database.Database;
import net.william278.husksync.listener.EventListener; import net.william278.husksync.listener.EventListener;
import net.william278.husksync.sync.DataSyncer;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.util.*; import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/** /**
* Plugin settings, read from config.yml * Plugin settings, read from config.yml
*/ */
@YamlFile(header = """ @SuppressWarnings("FieldMayBeFinal")
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ @Getter
┃ HuskSync Config ┃ @Configuration
┃ Developed by William278 ┃ @NoArgsConstructor(access = AccessLevel.PRIVATE)
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
┣╸ Information: https://william278.net/project/husksync
┣╸ Config Help: https://william278.net/docs/husksync/config-file/
┗╸ Documentation: https://william278.net/docs/husksync""")
public class Settings { public class Settings {
// Top-level settings protected static final String CONFIG_HEADER = """
@YamlComment("Locale of the default language file to use. Docs: https://william278.net/docs/huskhomes/translations") ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
@YamlKey("language") ┃ HuskSync Config ┃
private String language = "en-gb"; ┃ Developed by William278 ┃
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
┣╸ Information: https://william278.net/project/husksync
┣╸ Config Help: https://william278.net/docs/husksync/config-file/
┗╸ Documentation: https://william278.net/docs/husksync""";
@YamlComment("Whether to automatically check for plugin updates on startup") // Top-level settings
@YamlKey("check_for_updates") @Comment({"Locale of the default language file to use.", "Docs: https://william278.net/docs/husksync/translations"})
private String language = Locales.DEFAULT_LOCALE;
@Comment("Whether to automatically check for plugin updates on startup")
private boolean checkForUpdates = true; private boolean checkForUpdates = true;
@YamlComment("Specify a common ID for grouping servers running HuskSync. " @Comment("Specify a common ID for grouping servers running HuskSync. "
+ "Don't modify this unless you know what you're doing!") + "Don't modify this unless you know what you're doing!")
@YamlKey("cluster_id")
private String clusterId = ""; private String clusterId = "";
@YamlComment("Enable development debug logging") @Comment("Enable development debug logging")
@YamlKey("debug_logging")
private boolean debugLogging = false; private boolean debugLogging = false;
@YamlComment("Whether to provide modern, rich TAB suggestions for commands (if available)") @Comment({"Whether to enable the Player Analytics hook.", "Docs: https://william278.net/docs/husksync/plan-hook"})
@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; private boolean enablePlanHook = true;
@Comment("Whether to cancel game event packets directly when handling locked players if ProtocolLib or PacketEvents is installed")
private boolean cancelPackets = true;
@Comment("Add HuskSync commands to this list to prevent them from being registered (e.g. ['userdata'])")
@Getter(AccessLevel.NONE)
private List<String> disabledCommands = Lists.newArrayList();
// Database settings // Database settings
@YamlComment("Type of database to use (MYSQL, MARIADB)") @Comment("Database settings")
@YamlKey("database.type") private DatabaseSettings database = new DatabaseSettings();
private Database.Type databaseType = Database.Type.MYSQL;
@YamlComment("Specify credentials here for your MYSQL or MARIADB database") @Getter
@YamlKey("database.credentials.host") @Configuration
private String mySqlHost = "localhost"; @NoArgsConstructor(access = AccessLevel.PRIVATE)
public static class DatabaseSettings {
@YamlKey("database.credentials.port") @Comment("Type of database to use (MYSQL, MARIADB, POSTGRES, MONGO)")
private int mySqlPort = 3306; private Database.Type type = Database.Type.MYSQL;
@YamlKey("database.credentials.database") @Comment("Specify credentials here for your MYSQL, MARIADB, POSTGRES OR MONGO database")
private String mySqlDatabase = "HuskSync"; private DatabaseCredentials credentials = new DatabaseCredentials();
@YamlKey("database.credentials.username") @Getter
private String mySqlUsername = "root"; @Configuration
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static class DatabaseCredentials {
private String host = "localhost";
private int port = 3306;
private String database = "HuskSync";
private String username = "root";
private String password = "pa55w0rd";
@Comment("Only change this if you're using MARIADB or POSTGRES")
private String parameters = String.join("&",
"?autoReconnect=true", "useSSL=false",
"useUnicode=true", "characterEncoding=UTF-8");
}
@YamlKey("database.credentials.password") @Comment("MYSQL, MARIADB, POSTGRES database Hikari connection pool properties. Don't modify this unless you know what you're doing!")
private String mySqlPassword = "pa55w0rd"; private PoolSettings connectionPool = new PoolSettings();
@YamlKey("database.credentials.parameters") @Getter
private String mySqlConnectionParameters = "?autoReconnect=true" @Configuration
+ "&useSSL=false" @NoArgsConstructor(access = AccessLevel.PRIVATE)
+ "&useUnicode=true" public static class PoolSettings {
+ "&characterEncoding=UTF-8"; private int maximumPoolSize = 10;
private int minimumIdle = 10;
private long maximumLifetime = 1800000;
private long keepaliveTime = 0;
private long connectionTimeout = 5000;
}
@YamlComment("MYSQL / MARIADB database Hikari connection pool properties. " @Comment("Advanced MongoDB settings. Don't modify unless you know what you're doing!")
+ "Don't modify this unless you know what you're doing!") private MongoSettings mongoSettings = new MongoSettings();
@YamlKey("database.connection_pool.maximum_pool_size")
private int mySqlConnectionPoolSize = 10;
@YamlKey("database.connection_pool.minimum_idle") @Getter
private int mySqlConnectionPoolIdle = 10; @Configuration
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static class MongoSettings {
private boolean usingAtlas = false;
private String parameters = String.join("&",
"?retryWrites=true", "w=majority",
"authSource=HuskSync");
}
@YamlKey("database.connection_pool.maximum_lifetime") @Comment("Names of tables to use on your database. Don't modify this unless you know what you're doing!")
private long mySqlConnectionPoolLifetime = 1800000; @Getter(AccessLevel.NONE)
private Map<String, String> tableNames = Database.TableName.getDefaults();
@YamlKey("database.connection_pool.keepalive_time") @Comment("Whether to run the creation SQL on the database when the server starts. Don't modify this unless you know what you're doing!")
private long mySqlConnectionPoolKeepAlive = 0; private boolean createTables = true;
@YamlKey("database.connection_pool.connection_timeout") @NotNull
private long mySqlConnectionPoolTimeout = 5000; public String getTableName(@NotNull Database.TableName tableName) {
return tableNames.getOrDefault(tableName.name().toLowerCase(Locale.ENGLISH), tableName.getDefaultName());
}
}
@YamlComment("Names of tables to use on your database. Don't modify this unless you know what you're doing!") // 𝓡𝓮𝓭𝓲𝓼 settings
@YamlKey("database.table_names") @Comment("Redis settings")
private Map<String, String> tableNames = TableName.getDefaults(); private RedisSettings redis = new RedisSettings();
@Getter
@Configuration
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static class RedisSettings {
// Redis settings @Comment("Specify the credentials of your Redis database here. Set \"password\" to '' if you don't have one")
@YamlComment("Specify the credentials of your Redis database here. Set \"password\" to '' if you don't have one") private RedisCredentials credentials = new RedisCredentials();
@YamlKey("redis.credentials.host")
private String redisHost = "localhost";
@YamlKey("redis.credentials.port") @Getter
private int redisPort = 6379; @Configuration
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static class RedisCredentials {
private String host = "localhost";
private int port = 6379;
private String password = "";
private boolean useSsl = false;
}
@YamlKey("redis.credentials.password") @Comment("Options for if you're using Redis sentinel. Don't modify this unless you know what you're doing!")
private String redisPassword = ""; private RedisSentinel sentinel = new RedisSentinel();
@YamlKey("redis.use_ssl") @Getter
private boolean redisUseSsl = false; @Configuration
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static class RedisSentinel {
@Comment("The master set name for the Redis sentinel.")
private String master = "";
@Comment("List of host:port pairs")
private List<String> nodes = Lists.newArrayList();
private String password = "";
}
}
// Synchronization settings // Synchronization settings
@YamlComment("The number of data snapshot backups that should be kept at once per user") @Comment("Data syncing settings")
@YamlKey("synchronization.max_user_data_snapshots") private SynchronizationSettings synchronization = new SynchronizationSettings();
private int maxUserDataSnapshots = 16;
@YamlComment("Number of hours between new snapshots being saved as backups (Use \"0\" to backup all snapshots)") @Getter
@YamlKey("synchronization.snapshot_backup_frequency") @Configuration
private int snapshotBackupFrequency = 4; @NoArgsConstructor(access = AccessLevel.PRIVATE)
public static class SynchronizationSettings {
@YamlComment("List of save cause IDs for which a snapshot will be automatically pinned (so it won't be rotated)." @Comment({"The data synchronization mode to use (LOCKSTEP or DELAY). LOCKSTEP is recommended for most networks.",
+ " Docs: https://william278.net/docs/husksync/data-rotation#save-causes") "Docs: https://william278.net/docs/husksync/sync-modes"})
@YamlKey("synchronization.auto_pinned_save_causes") private DataSyncer.Mode mode = DataSyncer.Mode.LOCKSTEP;
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") @Comment("The number of data snapshot backups that should be kept at once per user")
@YamlKey("synchronization.save_on_world_save") private int maxUserDataSnapshots = 16;
private boolean saveOnWorldSave = true;
@YamlComment("Whether to create a snapshot for users when they die (containing their death drops)") @Comment("Number of hours between new snapshots being saved as backups (Use \"0\" to backup all snapshots)")
@YamlKey("synchronization.save_on_death") private int snapshotBackupFrequency = 4;
private boolean saveOnDeath = false;
@YamlComment("Whether to save empty death drops for users when they die") @Comment({"List of save cause IDs for which a snapshot will be automatically pinned (so it won't be rotated).",
@YamlKey("synchronization.save_empty_drops_on_death") "Docs: https://william278.net/docs/husksync/data-rotation#save-causes"})
private boolean saveEmptyDropsOnDeath = true; @Getter(AccessLevel.NONE)
private List<String> autoPinnedSaveCauses = List.of(
DataSnapshot.SaveCause.INVENTORY_COMMAND.name(),
DataSnapshot.SaveCause.ENDERCHEST_COMMAND.name(),
DataSnapshot.SaveCause.BACKUP_RESTORE.name(),
DataSnapshot.SaveCause.LEGACY_MIGRATION.name(),
DataSnapshot.SaveCause.MPDB_MIGRATION.name()
);
@YamlComment("Whether to use the snappy data compression algorithm. Keep on unless you know what you're doing") @Comment("Whether to create a snapshot for users on a world when the server saves that world")
@YamlKey("synchronization.compress_data") private boolean saveOnWorldSave = true;
private boolean compressData = true;
@YamlComment("Where to display sync notifications (ACTION_BAR, CHAT, TOAST or NONE)") @Comment("Configuration for how and when to sync player data when they die")
@YamlKey("synchronization.notification_display_slot") private SaveOnDeathSettings saveOnDeath = new SaveOnDeathSettings();
private Locales.NotificationSlot notificationSlot = Locales.NotificationSlot.ACTION_BAR;
@YamlComment("(Experimental) Persist Cartography Table locked maps to let them be viewed on any server") @Getter
@YamlKey("synchronization.persist_locked_maps") @Configuration
private boolean persistLockedMaps = true; @NoArgsConstructor(access = AccessLevel.PRIVATE)
public static class SaveOnDeathSettings {
@Comment("Whether to create a snapshot for users when they die (containing their death drops)")
private boolean enabled = false;
@YamlComment("Whether to synchronize player max health (requires health syncing to be enabled)") @Comment("What items to save in death snapshots? (DROPS or ITEMS_TO_KEEP). "
@YamlKey("synchronization.synchronize_max_health") + "Note that ITEMS_TO_KEEP (suggested for keepInventory servers) requires a Paper 1.19.4+ server.")
private boolean synchronizeMaxHealth = true; private DeathItemsMode itemsToSave = DeathItemsMode.DROPS;
@YamlComment("Whether dead players who log out and log in to a different server should have their items saved. " @Comment("Should a death snapshot still be created even if the items to save on the player's death are empty?")
+ "You may need to modify this if you're using the keepInventory gamerule.") private boolean saveEmptyItems = true;
@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 " @Comment("Whether dead players who log out and log in to a different server should have their items saved.")
+ "pulling data from the database instead (i.e., if the user did not change servers).") private boolean syncDeadPlayersChangingServer = true;
@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") * Represents the mode of saving items on death
private Map<String, Boolean> synchronizationFeatures = Identifier.getConfigMap(); */
public enum DeathItemsMode {
@YamlComment("Commands which should be blocked before a player has finished syncing (Use * to block all commands)") DROPS,
@YamlKey("synchronization.blacklisted_commands_while_locked") ITEMS_TO_KEEP
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> syncEventPriorities = EventListener.ListenerType.getDefaults();
// Zero-args constructor for instantiation via Annotaml
@SuppressWarnings("unused")
public Settings() {
}
@NotNull
public String getLanguage() {
return language;
}
public boolean doCheckForUpdates() {
return checkForUpdates;
}
@NotNull
public String getClusterId() {
return clusterId;
}
public boolean doDebugLogging() {
return debugLogging;
}
public boolean doBrigadierTabCompletion() {
return brigadierTabCompletion;
}
public boolean usePlanHook() {
return enablePlanHook;
}
@NotNull
public Database.Type getDatabaseType() {
return databaseType;
}
@NotNull
public String getMySqlHost() {
return mySqlHost;
}
public int getMySqlPort() {
return mySqlPort;
}
@NotNull
public String getMySqlDatabase() {
return mySqlDatabase;
}
@NotNull
public String getMySqlUsername() {
return mySqlUsername;
}
@NotNull
public String getMySqlPassword() {
return mySqlPassword;
}
@NotNull
public String getMySqlConnectionParameters() {
return mySqlConnectionParameters;
}
@NotNull
public String getTableName(@NotNull TableName tableName) {
return tableNames.getOrDefault(tableName.name().toLowerCase(Locale.ENGLISH), tableName.defaultName);
}
public int getMySqlConnectionPoolSize() {
return mySqlConnectionPoolSize;
}
public int getMySqlConnectionPoolIdle() {
return mySqlConnectionPoolIdle;
}
public long getMySqlConnectionPoolLifetime() {
return mySqlConnectionPoolLifetime;
}
public long getMySqlConnectionPoolKeepAlive() {
return mySqlConnectionPoolKeepAlive;
}
public long getMySqlConnectionPoolTimeout() {
return mySqlConnectionPoolTimeout;
}
@NotNull
public String getRedisHost() {
return redisHost;
}
public int getRedisPort() {
return redisPort;
}
@NotNull
public String getRedisPassword() {
return redisPassword;
}
public boolean redisUseSsl() {
return redisUseSsl;
}
public int getMaxUserDataSnapshots() {
return maxUserDataSnapshots;
}
public int getBackupFrequency() {
return snapshotBackupFrequency;
}
public boolean doSaveOnWorldSave() {
return saveOnWorldSave;
}
public boolean doSaveOnDeath() {
return saveOnDeath;
}
public boolean doSaveEmptyDropsOnDeath() {
return saveEmptyDropsOnDeath;
}
public boolean doCompressData() {
return compressData;
}
public boolean doAutoPin(@NotNull DataSnapshot.SaveCause cause) {
return autoPinnedSaveCauses.contains(cause.name());
}
@NotNull
public Locales.NotificationSlot getNotificationDisplaySlot() {
return notificationSlot;
}
public boolean doPersistLockedMaps() {
return persistLockedMaps;
}
public boolean doSynchronizeDeadPlayersChangingServer() {
return synchronizeDeadPlayersChangingServer;
}
public boolean doSynchronizeMaxHealth() {
return synchronizeMaxHealth;
}
public int getNetworkLatencyMilliseconds() {
return networkLatencyMilliseconds;
}
@NotNull
public Map<String, Boolean> getSynchronizationFeatures() {
return synchronizationFeatures;
}
public boolean isSyncFeatureEnabled(@NotNull Identifier id) {
return id.isCustom() || getSynchronizationFeatures().getOrDefault(id.getKeyValue(), id.isEnabledByDefault());
}
@NotNull
public List<String> getBlacklistedCommandsWhileLocked() {
return blacklistedCommandsWhileLocked;
}
@NotNull
public EventListener.Priority getEventPriority(@NotNull EventListener.ListenerType type) {
try {
return EventListener.Priority.valueOf(syncEventPriorities.get(type.name().toLowerCase(Locale.ENGLISH)));
} catch (IllegalArgumentException e) {
return EventListener.Priority.NORMAL;
} }
}
/** @Comment("Whether to use the snappy data compression algorithm. Keep on unless you know what you're doing")
* Represents the names of tables in the database private boolean compressData = true;
*/
public enum TableName {
USERS("husksync_users"),
USER_DATA("husksync_user_data");
private final String defaultName; @Comment("Where to display sync notifications (ACTION_BAR, CHAT or NONE)")
private Locales.NotificationSlot notificationDisplaySlot = Locales.NotificationSlot.ACTION_BAR;
TableName(@NotNull String defaultName) { @Comment("Persist maps locked in a Cartography Table to let them be viewed on any server")
this.defaultName = defaultName; private boolean persistLockedMaps = true;
@Comment("If using the DELAY sync method, how long should this server listen for Redis key data updates before "
+ "pulling data from the database instead (i.e., if the user did not change servers).")
private int networkLatencyMilliseconds = 500;
@Comment({"Which data types to synchronize.", "Docs: https://william278.net/docs/husksync/sync-features"})
@Getter(AccessLevel.NONE)
private Map<String, Boolean> features = Identifier.getConfigMap();
@Comment("Commands which should be blocked before a player has finished syncing (Use * to block all commands)")
private List<String> blacklistedCommandsWhileLocked = new ArrayList<>(List.of("*"));
@Comment("Configuration for how to sync attributes")
private AttributeSettings attributes = new AttributeSettings();
@Getter
@Configuration
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static class AttributeSettings {
@Comment({"Which attribute types should be saved as part of attribute syncing. Supports wildcard matching.",
"(e.g. ['minecraft:generic.max_health', 'minecraft:generic.*'])"})
@Getter(AccessLevel.NONE)
private List<String> syncedAttributes = new ArrayList<>(List.of(
"minecraft:generic.max_health", "minecraft:max_health",
"minecraft:generic.max_absorption", "minecraft:max_absorption",
"minecraft:generic.luck", "minecraft:luck",
"minecraft:generic.scale", "minecraft:scale",
"minecraft:generic.step_height", "minecraft:step_height",
"minecraft:generic.gravity", "minecraft:gravity"
));
@Comment({"Which attribute modifiers should be saved. Supports wildcard matching.",
"(e.g. ['minecraft:effect.speed', 'minecraft:effect.*'])"})
@Getter(AccessLevel.NONE)
private List<String> ignoredModifiers = new ArrayList<>(List.of(
"minecraft:effect.*", "minecraft:creative_mode_*"
));
private boolean matchesWildcard(@NotNull String pat, @NotNull String value) {
if (!pat.contains(":")) {
pat = "minecraft:%s".formatted(pat);
}
if (!value.contains(":")) {
value = "minecraft:%s".formatted(value);
}
return pat.contains("*") ? value.matches(pat.replace("*", ".*")) : pat.equals(value);
}
public boolean isIgnoredAttribute(@NotNull String attribute) {
return syncedAttributes.stream().noneMatch(wildcard -> matchesWildcard(wildcard, attribute));
}
public boolean isIgnoredModifier(@NotNull String modifier) {
return ignoredModifiers.stream().anyMatch(wildcard -> matchesWildcard(wildcard, modifier));
}
}
@Comment("Event priorities for listeners (HIGHEST, NORMAL, LOWEST). Change if you encounter plugin conflicts")
@Getter(AccessLevel.NONE)
private Map<String, String> eventPriorities = EventListener.ListenerType.getDefaults();
public boolean doAutoPin(@NotNull DataSnapshot.SaveCause cause) {
return autoPinnedSaveCauses.contains(cause.name());
}
public boolean isFeatureEnabled(@NotNull Identifier id) {
return id.isCustom() || features.getOrDefault(id.getKeyValue(), id.isEnabledByDefault());
} }
@NotNull @NotNull
private Map.Entry<String, String> toEntry() { public EventListener.Priority getEventPriority(@NotNull EventListener.ListenerType type) {
return Map.entry(name().toLowerCase(Locale.ENGLISH), defaultName); try {
} return EventListener.Priority.valueOf(eventPriorities.get(type.name().toLowerCase(Locale.ENGLISH)));
} catch (IllegalArgumentException e) {
@SuppressWarnings("unchecked") return EventListener.Priority.NORMAL;
@NotNull }
private static Map<String, String> getDefaults() {
return Map.ofEntries(Arrays.stream(values())
.map(TableName::toEntry)
.toArray(Map.Entry[]::new));
} }
} }
public boolean isCommandDisabled(@NotNull PluginCommand command) {
return disabledCommands.stream().map(c -> c.startsWith("/") ? c.substring(1) : c)
.anyMatch(c -> c.equalsIgnoreCase(command.getName()) || command.getAliases().contains(c));
}
} }

View File

@@ -19,7 +19,13 @@
package net.william278.husksync.data; package net.william278.husksync.data;
import com.google.common.collect.Sets;
import com.google.gson.annotations.SerializedName; import com.google.gson.annotations.SerializedName;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.experimental.Accessors;
import net.kyori.adventure.key.Key;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
import net.william278.husksync.user.OnlineUser; import net.william278.husksync.user.OnlineUser;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
@@ -51,8 +57,8 @@ public interface Data {
*/ */
interface Items extends Data { interface Items extends Data {
@NotNull @Nullable
Stack[] getStack(); Stack @NotNull [] getStack();
default int getSlotCount() { default int getSlotCount() {
return getStack().length; return getStack().length;
@@ -76,6 +82,10 @@ public interface Data {
*/ */
interface Inventory extends Items { interface Inventory extends Items {
int INVENTORY_SLOT_COUNT = 41;
String ITEMS_TAG = "items";
String HELD_ITEM_SLOT_TAG = "held_item_slot";
int getHeldItemSlot(); int getHeldItemSlot();
void setHeldItemSlot(int heldItemSlot) throws IllegalArgumentException; void setHeldItemSlot(int heldItemSlot) throws IllegalArgumentException;
@@ -105,7 +115,7 @@ public interface Data {
* Data container holding data for ender chests * Data container holding data for ender chests
*/ */
interface EnderChest extends Items { interface EnderChest extends Items {
int ENDER_CHEST_SLOT_COUNT = 27;
} }
} }
@@ -121,7 +131,7 @@ public interface Data {
/** /**
* Represents a potion effect * Represents a potion effect
* *
* @param type the type of potion effect * @param type the key of potion effect
* @param amplifier the amplifier of the potion effect * @param amplifier the amplifier of the potion effect
* @param duration the duration of the potion effect * @param duration the duration of the potion effect
* @param isAmbient whether the potion effect is ambient * @param isAmbient whether the potion effect is ambient
@@ -144,14 +154,14 @@ public interface Data {
*/ */
interface Advancements extends Data { interface Advancements extends Data {
String RECIPE_ADVANCEMENT = "minecraft:recipe";
@NotNull @NotNull
List<Advancement> getCompleted(); List<Advancement> getCompleted();
@NotNull @NotNull
default List<Advancement> getCompletedExcludingRecipes() { default List<Advancement> getCompletedExcludingRecipes() {
return getCompleted().stream() return getCompleted().stream().filter(adv -> !adv.getKey().startsWith(RECIPE_ADVANCEMENT)).toList();
.filter(advancement -> !advancement.getKey().startsWith("minecraft:recipe"))
.collect(Collectors.toList());
} }
void setCompleted(@NotNull List<Advancement> completed); void setCompleted(@NotNull List<Advancement> completed);
@@ -283,15 +293,148 @@ public interface Data {
void setHealth(double health); void setHealth(double health);
double getMaxHealth(); /**
* @deprecated Use {@link Attributes#getMaxHealth()} instead
*/
@Deprecated(forRemoval = true, since = "3.5")
default double getMaxHealth() {
return getHealth();
}
void setMaxHealth(double maxHealth); /**
* @deprecated Use {@link Attributes#setMaxHealth(double)} instead
*/
@Deprecated(forRemoval = true, since = "3.5")
default void setMaxHealth(double maxHealth) {
}
double getHealthScale(); double getHealthScale();
void setHealthScale(double healthScale); void setHealthScale(double healthScale);
} }
/**
* A data container holding player attribute data
*/
interface Attributes extends Data {
Key MAX_HEALTH_KEY = Key.key("generic.max_health");
List<Attribute> getAttributes();
record Attribute(
@NotNull String name,
double baseValue,
@NotNull Set<Modifier> modifiers
) {
public double getValue() {
double value = baseValue;
for (Modifier modifier : modifiers) {
value = modifier.modify(value);
}
return value;
}
}
@Getter
@Accessors(fluent = true)
@RequiredArgsConstructor
final class Modifier {
final static String ANY_EQUIPMENT_SLOT_GROUP = "any";
@Getter(AccessLevel.NONE)
@Nullable
@SerializedName("uuid")
private UUID uuid = null;
// Since 1.21.1: Name, amount, operation, slotGroup
@SerializedName("name")
private String name;
@SerializedName("amount")
private double amount;
@SerializedName("operation")
private int operation;
@SerializedName("equipment_slot")
@Deprecated(since = "3.7")
private int equipmentSlot;
@SerializedName("equipment_slot_group")
private String slotGroup = ANY_EQUIPMENT_SLOT_GROUP;
public Modifier(@NotNull String name, double amount, int operation, @NotNull String slotGroup) {
this.name = name;
this.amount = amount;
this.operation = operation;
this.slotGroup = slotGroup;
}
@Deprecated(since = "3.7")
public Modifier(@NotNull UUID uuid, @NotNull String name, double amount, int operation, int equipmentSlot) {
this.name = name;
this.amount = amount;
this.operation = operation;
this.equipmentSlot = equipmentSlot;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof Modifier other) {
if (uuid != null && other.uuid != null) {
return uuid.equals(other.uuid);
}
return name.equals(other.name);
}
return super.equals(obj);
}
public double modify(double value) {
return switch (operation) {
case 0 -> value + amount;
case 1 -> value * amount;
case 2 -> value * (1 + amount);
default -> value;
};
}
public boolean hasUuid() {
return uuid != null;
}
@NotNull
public UUID uuid() {
return uuid != null ? uuid : UUID.nameUUIDFromBytes(name.getBytes());
}
}
default Optional<Attribute> getAttribute(@NotNull Key key) {
return getAttributes().stream()
.filter(attribute -> attribute.name().equals(key.asString()))
.findFirst();
}
default void removeAttribute(@NotNull Key key) {
getAttributes().removeIf(attribute -> attribute.name().equals(key.asString()));
}
default double getMaxHealth() {
return getAttribute(MAX_HEALTH_KEY)
.map(Attribute::getValue)
.orElse(20.0);
}
default void setMaxHealth(double maxHealth) {
removeAttribute(MAX_HEALTH_KEY);
getAttributes().add(new Attribute(MAX_HEALTH_KEY.asString(), maxHealth, Sets.newHashSet()));
}
}
/** /**
* A data container holding data for: * A data container holding data for:
* <ul> * <ul>
@@ -341,12 +484,7 @@ public interface Data {
} }
/** /**
* A data container holding data for: * Data container holding data for the player's current game mode
* <ul>
* <li>Game mode</li>
* <li>Allow flight</li>
* <li>Is flying</li>
* </ul>
*/ */
interface GameMode extends Data { interface GameMode extends Data {
@@ -355,13 +493,65 @@ public interface Data {
void setGameMode(@NotNull String gameMode); void setGameMode(@NotNull String gameMode);
boolean getAllowFlight(); /**
* Get if the player can fly.
*
* @return {@code false} since v3.5
* @deprecated Moved to its own data type. This will always return {@code false}.
* Use {@link FlightStatus#isAllowFlight()} instead
*/
@Deprecated(forRemoval = true, since = "3.5")
default boolean getAllowFlight() {
return false;
}
/**
* Set if the player can fly.
*
* @deprecated Moved to its own data type.
* Use {@link FlightStatus#setAllowFlight(boolean)} instead
*/
@Deprecated(forRemoval = true, since = "3.5")
default void setAllowFlight(boolean allowFlight) {
}
/**
* Get if the player is flying.
*
* @return {@code false} since v3.5
* @deprecated Moved to its own data type. This will always return {@code false}.
* Use {@link FlightStatus#isFlying()} instead
*/
@Deprecated(forRemoval = true, since = "3.5")
default boolean getIsFlying() {
return false;
}
/**
* Set if the player is flying.
*
* @deprecated Moved to its own data type.
* Use {@link FlightStatus#setFlying(boolean)} instead
*/
@Deprecated(forRemoval = true, since = "3.5")
default void setIsFlying(boolean isFlying) {
}
}
/**
* Data container holding data for the player's flight status
*
* @since 3.5
*/
interface FlightStatus extends Data {
boolean isAllowFlight();
void setAllowFlight(boolean allowFlight); void setAllowFlight(boolean allowFlight);
boolean getIsFlying(); boolean isFlying();
void setIsFlying(boolean isFlying); void setFlying(boolean isFlying);
} }

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.data;
import lombok.AllArgsConstructor;
import lombok.Getter;
import net.william278.husksync.HuskSync;
import org.jetbrains.annotations.NotNull;
import java.util.function.BiFunction;
/**
* An exception related to {@link DataSnapshot} formatting, thrown if an exception occurs when unpacking a snapshot
*/
@Getter
public class DataException extends IllegalStateException {
private final Reason reason;
private DataException(@NotNull DataException.Reason reason, @NotNull DataSnapshot data, @NotNull HuskSync plugin) {
super(reason.getMessage(plugin, data));
this.reason = reason;
}
/**
* Reasons why {@link DataException}s were thrown
*/
@AllArgsConstructor
public enum Reason {
INVALID_MINECRAFT_VERSION((plugin, snapshot) -> String.format("The Minecraft version of the snapshot (%s) is " +
"newer than the server's version (%s). Ensure each server is on the same version of Minecraft.",
snapshot.getMinecraftVersion(), plugin.getMinecraftVersion())),
INVALID_FORMAT_VERSION((plugin, snapshot) -> String.format("The format version of the snapshot (%s) is newer " +
"than the server's version (%s). Ensure each server is running the same version of HuskSync.",
snapshot.getFormatVersion(), DataSnapshot.CURRENT_FORMAT_VERSION)),
INVALID_PLATFORM_TYPE((plugin, snapshot) -> String.format("The platform type of the snapshot (%s) does " +
"not match the server's platform type (%s). Ensure each server has the same platform type.",
snapshot.getPlatformType(), plugin.getPlatformType())),
NO_LEGACY_CONVERTER((plugin, snapshot) -> String.format("No legacy converter to convert format version: %s",
snapshot.getFormatVersion()));
private final BiFunction<HuskSync, DataSnapshot, String> exception;
@NotNull
String getMessage(@NotNull HuskSync plugin, @NotNull DataSnapshot data) {
return exception.apply(plugin, data);
}
@NotNull
DataException toException(@NotNull DataSnapshot data, @NotNull HuskSync plugin) {
return new DataException(this, data, plugin);
}
}
}

View File

@@ -30,8 +30,8 @@ public interface DataHolder {
@NotNull @NotNull
Map<Identifier, Data> getData(); Map<Identifier, Data> getData();
default Optional<? extends Data> getData(@NotNull Identifier identifier) { default Optional<? extends Data> getData(@NotNull Identifier id) {
return Optional.ofNullable(getData().get(identifier)); return getData().entrySet().stream().filter(e -> e.getKey().equals(id)).map(Map.Entry::getValue).findFirst();
} }
default void setData(@NotNull Identifier identifier, @NotNull Data data) { default void setData(@NotNull Identifier identifier, @NotNull Data data) {
@@ -110,6 +110,15 @@ public interface DataHolder {
getData().put(Identifier.HUNGER, hunger); getData().put(Identifier.HUNGER, hunger);
} }
@NotNull
default Optional<Data.Attributes> getAttributes() {
return Optional.ofNullable((Data.Attributes) getData().get(Identifier.ATTRIBUTES));
}
default void setAttributes(@NotNull Data.Attributes attributes) {
getData().put(Identifier.ATTRIBUTES, attributes);
}
@NotNull @NotNull
default Optional<Data.Experience> getExperience() { default Optional<Data.Experience> getExperience() {
return Optional.ofNullable((Data.Experience) getData().get(Identifier.EXPERIENCE)); return Optional.ofNullable((Data.Experience) getData().get(Identifier.EXPERIENCE));
@@ -128,6 +137,15 @@ public interface DataHolder {
getData().put(Identifier.GAME_MODE, gameMode); getData().put(Identifier.GAME_MODE, gameMode);
} }
@NotNull
default Optional<Data.FlightStatus> getFlightStatus() {
return Optional.ofNullable((Data.FlightStatus) getData().get(Identifier.FLIGHT_STATUS));
}
default void setFlightStatus(@NotNull Data.FlightStatus flightStatus) {
getData().put(Identifier.FLIGHT_STATUS, flightStatus);
}
@NotNull @NotNull
default Optional<Data.PersistentData> getPersistentData() { default Optional<Data.PersistentData> getPersistentData() {
return Optional.ofNullable((Data.PersistentData) getData().get(Identifier.PERSISTENT_DATA)); return Optional.ofNullable((Data.PersistentData) getData().get(Identifier.PERSISTENT_DATA));

View File

@@ -19,14 +19,20 @@
package net.william278.husksync.data; package net.william278.husksync.data;
import com.google.common.collect.Maps;
import com.google.gson.annotations.Expose; import com.google.gson.annotations.Expose;
import com.google.gson.annotations.SerializedName; import com.google.gson.annotations.SerializedName;
import de.themoep.minedown.adventure.MineDown; import de.themoep.minedown.adventure.MineDown;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import net.william278.desertwell.util.Version; import net.william278.desertwell.util.Version;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.Adaptable; import net.william278.husksync.adapter.Adaptable;
import net.william278.husksync.adapter.DataAdapter; import net.william278.husksync.adapter.DataAdapter;
import net.william278.husksync.config.Locales; import org.apache.commons.text.WordUtils;
import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
@@ -41,13 +47,15 @@ import java.util.stream.Collectors;
* *
* @since 3.0 * @since 3.0
*/ */
@SuppressWarnings({"LombokSetterMayBeUsed", "LombokGetterMayBeUsed"})
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class DataSnapshot { public class DataSnapshot {
/* /*
* Current version of the snapshot data format. * Current version of the snapshot data format.
* HuskSync v3.0 uses v4; HuskSync v2.0 uses v1-v3 * HuskSync v3.1 uses v5, v3.0 uses v4; v2.0 uses v1-v3
*/ */
protected static final int CURRENT_FORMAT_VERSION = 4; protected static final int CURRENT_FORMAT_VERSION = 5;
@SerializedName("id") @SerializedName("id")
protected UUID id; protected UUID id;
@@ -59,7 +67,10 @@ public class DataSnapshot {
protected OffsetDateTime timestamp; protected OffsetDateTime timestamp;
@SerializedName("save_cause") @SerializedName("save_cause")
protected SaveCause saveCause; protected String saveCause;
@SerializedName("server_name")
protected String serverName;
@SerializedName("minecraft_version") @SerializedName("minecraft_version")
protected String minecraftVersion; protected String minecraftVersion;
@@ -73,23 +84,25 @@ public class DataSnapshot {
@SerializedName("data") @SerializedName("data")
protected Map<String, String> data; protected Map<String, String> data;
// If the snapshot is invalid, this will be set to the validation exception
@Nullable
@Expose(serialize = false, deserialize = false)
transient DataException.Reason exception = null;
private DataSnapshot(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp, private DataSnapshot(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp,
@NotNull SaveCause saveCause, @NotNull Map<String, String> data, @NotNull String saveCause, @NotNull String serverName, @NotNull Map<String, String> data,
@NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) { @NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) {
this.id = id; this.id = id;
this.pinned = pinned; this.pinned = pinned;
this.timestamp = timestamp; this.timestamp = timestamp;
this.saveCause = saveCause; this.saveCause = saveCause;
this.serverName = serverName;
this.data = data; this.data = data;
this.minecraftVersion = minecraftVersion.toStringWithoutMetadata(); this.minecraftVersion = minecraftVersion.toStringWithoutMetadata();
this.platformType = platformType; this.platformType = platformType;
this.formatVersion = formatVersion; this.formatVersion = formatVersion;
} }
@SuppressWarnings("unused")
private DataSnapshot() {
}
@NotNull @NotNull
@ApiStatus.Internal @ApiStatus.Internal
public static DataSnapshot.Builder builder(@NotNull HuskSync plugin) { public static DataSnapshot.Builder builder(@NotNull HuskSync plugin) {
@@ -100,37 +113,25 @@ public class DataSnapshot {
@NotNull @NotNull
@ApiStatus.Internal @ApiStatus.Internal
public static DataSnapshot.Packed deserialize(@NotNull HuskSync plugin, byte[] data, @Nullable UUID id, public static DataSnapshot.Packed deserialize(@NotNull HuskSync plugin, byte[] data, @Nullable UUID id,
@Nullable OffsetDateTime timestamp) throws IllegalStateException { @Nullable OffsetDateTime timestamp) {
final DataSnapshot.Packed snapshot = plugin.getDataAdapter().fromBytes(data, DataSnapshot.Packed.class); final DataSnapshot.Packed snapshot = plugin.getDataAdapter().fromBytes(data, DataSnapshot.Packed.class);
if (snapshot.getMinecraftVersion().compareTo(plugin.getMinecraftVersion()) > 0) { if (snapshot.getMinecraftVersion().compareTo(plugin.getMinecraftVersion()) > 0) {
throw new IllegalStateException(String.format("Cannot set data for user because the Minecraft version of " + return snapshot.invalid(DataException.Reason.INVALID_MINECRAFT_VERSION);
"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) { if (snapshot.getFormatVersion() > CURRENT_FORMAT_VERSION) {
throw new IllegalStateException(String.format("Cannot set data for user because the format version of " + return snapshot.invalid(DataException.Reason.INVALID_FORMAT_VERSION);
"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 (snapshot.getFormatVersion() < 4) {
if (plugin.getLegacyConverter().isPresent()) { if (plugin.getLegacyConverter().isPresent()) {
return plugin.getLegacyConverter().get().convert( return plugin.getLegacyConverter().get().convert(
data, data, Objects.requireNonNull(id, "Attempted legacy conversion with null UUID!"),
Objects.requireNonNull(id, "Attempted legacy conversion with null UUID!"),
Objects.requireNonNull(timestamp, "Attempted legacy conversion with null timestamp!") Objects.requireNonNull(timestamp, "Attempted legacy conversion with null timestamp!")
); );
} }
throw new IllegalStateException(String.format( return snapshot.invalid(DataException.Reason.NO_LEGACY_CONVERTER);
"No legacy converter to convert format version: %s", snapshot.getFormatVersion()
));
} }
if (!snapshot.getPlatformType().equalsIgnoreCase(plugin.getPlatformType())) { if (!snapshot.getPlatformType().equalsIgnoreCase(plugin.getPlatformType())) {
throw new IllegalStateException(String.format("Cannot set data for user because the platform type of " + return snapshot.invalid(DataException.Reason.INVALID_PLATFORM_TYPE);
"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 snapshot;
} }
@@ -153,6 +154,17 @@ public class DataSnapshot {
return id; return id;
} }
/**
* <b>Internal use only</b> Set the ID of the snapshot
*
* @param id The snapshot ID
* @since 3.0
*/
@ApiStatus.Internal
public void setId(@NotNull UUID id) {
this.id = id;
}
/** /**
* Get the short display ID of the snapshot * Get the short display ID of the snapshot
* *
@@ -192,7 +204,20 @@ public class DataSnapshot {
*/ */
@NotNull @NotNull
public SaveCause getSaveCause() { public SaveCause getSaveCause() {
return saveCause; return SaveCause.of(saveCause);
}
/**
* Get the server the snapshot was created on.
* <p>
* Note that snapshots generated before v3.1 will return {@code "N/A"}
*
* @return The server name
* @since 3.1
*/
@NotNull
public String getServerName() {
return Optional.ofNullable(serverName).orElse("N/A");
} }
/** /**
@@ -201,8 +226,8 @@ public class DataSnapshot {
* @param saveCause The {@link SaveCause data save cause} of the snapshot * @param saveCause The {@link SaveCause data save cause} of the snapshot
* @since 3.0 * @since 3.0
*/ */
public void setSaveCause(SaveCause saveCause) { public void setSaveCause(@NotNull SaveCause saveCause) {
this.saveCause = saveCause; this.saveCause = saveCause.name();
} }
/** /**
@@ -253,16 +278,39 @@ public class DataSnapshot {
* *
* @since 3.0 * @since 3.0
*/ */
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public static class Packed extends DataSnapshot implements Adaptable { public static class Packed extends DataSnapshot implements Adaptable {
protected Packed(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp, protected Packed(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp,
@NotNull SaveCause saveCause, @NotNull Map<String, String> data, @NotNull String saveCause, @NotNull String serverName, @NotNull Map<String, String> data,
@NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) { @NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) {
super(id, pinned, timestamp, saveCause, data, minecraftVersion, platformType, formatVersion); super(id, pinned, timestamp, saveCause, serverName, data, minecraftVersion, platformType, formatVersion);
} }
@SuppressWarnings("unused") @NotNull
private Packed() { @ApiStatus.Internal
DataSnapshot.Packed invalid(@NotNull DataException.Reason reason) {
this.exception = reason;
return this;
}
public boolean isInvalid() {
return exception != null;
}
@NotNull
public String getInvalidReason(@NotNull HuskSync plugin) {
if (exception == null) {
throw new IllegalStateException("Attempted to get an invalid reason for a valid snapshot!");
}
return exception.getMessage(plugin, this);
}
@ApiStatus.Internal
void validate(@NotNull HuskSync plugin) throws DataException {
if (exception != null) {
throw exception.toException(this, plugin);
}
} }
@ApiStatus.Internal @ApiStatus.Internal
@@ -270,7 +318,7 @@ public class DataSnapshot {
final Unpacked data = unpack(plugin); final Unpacked data = unpack(plugin);
editor.accept(data); editor.accept(data);
this.pinned = data.isPinned(); this.pinned = data.isPinned();
this.saveCause = data.getSaveCause(); this.saveCause = data.getSaveCause().name();
this.data = data.serializeData(plugin); this.data = data.serializeData(plugin);
} }
@@ -282,12 +330,11 @@ public class DataSnapshot {
@NotNull @NotNull
public Packed copy() { public Packed copy() {
return new Packed( return new Packed(
UUID.randomUUID(), pinned, OffsetDateTime.now(), saveCause, data, UUID.randomUUID(), pinned, OffsetDateTime.now(), saveCause, serverName,
getMinecraftVersion(), platformType, formatVersion data, getMinecraftVersion(), platformType, formatVersion
); );
} }
@NotNull
@ApiStatus.Internal @ApiStatus.Internal
public byte[] asBytes(@NotNull HuskSync plugin) throws DataAdapter.AdaptionException { public byte[] asBytes(@NotNull HuskSync plugin) throws DataAdapter.AdaptionException {
return plugin.getDataAdapter().toBytes(this); return plugin.getDataAdapter().toBytes(this);
@@ -305,9 +352,10 @@ public class DataSnapshot {
} }
@NotNull @NotNull
public DataSnapshot.Unpacked unpack(@NotNull HuskSync plugin) { public DataSnapshot.Unpacked unpack(@NotNull HuskSync plugin) throws DataException {
this.validate(plugin);
return new Unpacked( return new Unpacked(
id, pinned, timestamp, saveCause, data, id, pinned, timestamp, saveCause, serverName, data,
getMinecraftVersion(), platformType, formatVersion, plugin getMinecraftVersion(), platformType, formatVersion, plugin
); );
} }
@@ -322,44 +370,44 @@ public class DataSnapshot {
public static class Unpacked extends DataSnapshot implements DataHolder { public static class Unpacked extends DataSnapshot implements DataHolder {
@Expose(serialize = false, deserialize = false) @Expose(serialize = false, deserialize = false)
private final Map<Identifier, Data> deserialized; private final TreeMap<Identifier, Data> deserialized;
private Unpacked(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp, private Unpacked(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp,
@NotNull SaveCause saveCause, @NotNull Map<String, String> data, @NotNull String saveCause, @NotNull String serverName, @NotNull Map<String, String> data,
@NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion, @NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion,
@NotNull HuskSync plugin) { @NotNull HuskSync plugin) {
super(id, pinned, timestamp, saveCause, data, minecraftVersion, platformType, formatVersion); super(id, pinned, timestamp, saveCause, serverName, data, minecraftVersion, platformType, formatVersion);
this.deserialized = deserializeData(plugin); this.deserialized = deserializeData(plugin);
} }
private Unpacked(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp, private Unpacked(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp,
@NotNull SaveCause saveCause, @NotNull Map<Identifier, Data> data, @NotNull String saveCause, @NotNull String serverName, @NotNull TreeMap<Identifier, Data> data,
@NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) { @NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) {
super(id, pinned, timestamp, saveCause, Map.of(), minecraftVersion, platformType, formatVersion); super(id, pinned, timestamp, saveCause, serverName, Map.of(), minecraftVersion, platformType, formatVersion);
this.deserialized = data; this.deserialized = data;
} }
@NotNull @NotNull
@ApiStatus.Internal @ApiStatus.Internal
private Map<Identifier, Data> deserializeData(@NotNull HuskSync plugin) { private TreeMap<Identifier, Data> deserializeData(@NotNull HuskSync plugin) {
return data.entrySet().stream() return data.entrySet().stream()
.map((entry) -> plugin.getIdentifier(entry.getKey()).map(id -> Map.entry( .filter(e -> plugin.getIdentifier(e.getKey()).isPresent())
id, plugin.getSerializers().get(id).deserialize(entry.getValue()) .map(entry -> Map.entry(plugin.getIdentifier(entry.getKey()).orElseThrow(), entry.getValue()))
)).orElse(null)) .collect(Collectors.toMap(
.filter(Objects::nonNull) Map.Entry::getKey,
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); entry -> plugin.deserializeData(entry.getKey(), entry.getValue(), getMinecraftVersion()),
(a, b) -> b, () -> Maps.newTreeMap(SerializerRegistry.DEPENDENCY_ORDER_COMPARATOR)
));
} }
@NotNull @NotNull
@ApiStatus.Internal @ApiStatus.Internal
private Map<String, String> serializeData(@NotNull HuskSync plugin) { private Map<String, String> serializeData(@NotNull HuskSync plugin) {
return deserialized.entrySet().stream() return deserialized.entrySet().stream()
.map((entry) -> Map.entry(entry.getKey().toString(), .collect(Collectors.toMap(
Objects.requireNonNull( entry -> entry.getKey().toString(),
plugin.getSerializers().get(entry.getKey()), entry -> plugin.serializeData(entry.getKey(), entry.getValue())
String.format("No serializer found for %s", entry.getKey()) ));
).serialize(entry.getValue())))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
} }
/** /**
@@ -384,7 +432,7 @@ public class DataSnapshot {
@ApiStatus.Internal @ApiStatus.Internal
public DataSnapshot.Packed pack(@NotNull HuskSync plugin) { public DataSnapshot.Packed pack(@NotNull HuskSync plugin) {
return new DataSnapshot.Packed( return new DataSnapshot.Packed(
id, pinned, timestamp, saveCause, serializeData(plugin), id, pinned, timestamp, saveCause, serverName, serializeData(plugin),
getMinecraftVersion(), platformType, formatVersion getMinecraftVersion(), platformType, formatVersion
); );
} }
@@ -402,16 +450,18 @@ public class DataSnapshot {
private final HuskSync plugin; private final HuskSync plugin;
private UUID id; private UUID id;
private SaveCause saveCause; private SaveCause saveCause;
private String serverName;
private boolean pinned; private boolean pinned;
private OffsetDateTime timestamp; private OffsetDateTime timestamp;
private final Map<Identifier, Data> data; private final TreeMap<Identifier, Data> data;
private Builder(@NotNull HuskSync plugin) { private Builder(@NotNull HuskSync plugin) {
this.plugin = plugin; this.plugin = plugin;
this.pinned = false; this.pinned = false;
this.data = new HashMap<>(); this.data = Maps.newTreeMap(SerializerRegistry.DEPENDENCY_ORDER_COMPARATOR);
this.timestamp = OffsetDateTime.now(); this.timestamp = OffsetDateTime.now();
this.id = UUID.randomUUID(); this.id = UUID.randomUUID();
this.serverName = plugin.getServerName();
} }
/** /**
@@ -441,6 +491,19 @@ public class DataSnapshot {
return this; return this;
} }
/**
* Set the name of the server where this snapshot was created
*
* @param serverName The server name
* @return The builder
* @since 3.1
*/
@NotNull
public Builder serverName(@NotNull String serverName) {
this.serverName = serverName;
return this;
}
/** /**
* Set whether the data should be pinned * Set whether the data should be pinned
* *
@@ -471,7 +534,10 @@ public class DataSnapshot {
@NotNull @NotNull
public Builder timestamp(@NotNull OffsetDateTime timestamp) { public Builder timestamp(@NotNull OffsetDateTime timestamp) {
if (timestamp.isAfter(OffsetDateTime.now())) { if (timestamp.isAfter(OffsetDateTime.now())) {
throw new IllegalArgumentException("Data snapshots cannot have a timestamp set in the future"); throw new IllegalArgumentException("Data snapshots cannot have a timestamp set in the future! "
+ "Make sure your database server time matches the server time.\n"
+ "Current game server timestamp: " + OffsetDateTime.now() + " / "
+ "Snapshot timestamp: " + timestamp);
} }
this.timestamp = timestamp; this.timestamp = timestamp;
return this; return this;
@@ -624,6 +690,21 @@ public class DataSnapshot {
return data(Identifier.HUNGER, hunger); return data(Identifier.HUNGER, hunger);
} }
/**
* Set the attributes of the snapshot
* <p>
* Equivalent to {@code data(Identifier.ATTRIBUTES, attributes)}
* </p>
*
* @param attributes The user's attributes
* @return The builder
* @since 3.5
*/
@NotNull
public Builder attributes(@NotNull Data.Attributes attributes) {
return data(Identifier.ATTRIBUTES, attributes);
}
/** /**
* Set the experience of the snapshot * Set the experience of the snapshot
* <p> * <p>
@@ -654,6 +735,21 @@ public class DataSnapshot {
return data(Identifier.GAME_MODE, gameMode); return data(Identifier.GAME_MODE, gameMode);
} }
/**
* Set the flight status of the snapshot
* <p>
* Equivalent to {@code data(Identifier.FLIGHT_STATUS, flightStatus)}
* </p>
*
* @param flightStatus The flight status
* @return The builder
* @since 3.5
*/
@NotNull
public Builder flightStatus(@NotNull Data.FlightStatus flightStatus) {
return data(Identifier.FLIGHT_STATUS, flightStatus);
}
/** /**
* Set the persistent data container of the snapshot * Set the persistent data container of the snapshot
* <p> * <p>
@@ -683,9 +779,10 @@ public class DataSnapshot {
} }
return new Unpacked( return new Unpacked(
id, id,
pinned || plugin.getSettings().doAutoPin(saveCause), pinned || plugin.getSettings().getSynchronization().doAutoPin(saveCause),
timestamp, timestamp,
saveCause, saveCause.name(),
serverName,
data, data,
plugin.getMinecraftVersion(), plugin.getMinecraftVersion(),
plugin.getPlatformType(), plugin.getPlatformType(),
@@ -707,134 +804,275 @@ public class DataSnapshot {
} }
public interface Cause {
@NotNull
String name();
/**
* Returns the capitalized display name of the cause.
*
* @return the cause display name
*/
@NotNull
default String getDisplayName() {
return WordUtils.capitalizeFully(name().replaceAll("_", " "));
}
}
/** /**
* Identifies the cause of a player data save. * A string wrapper, for identifying the cause of a player data save.
*
* @implNote This enum is saved in the database.
* </p> * </p>
* Cause names have a max length of 32 characters. * Cause names have a max length of 32 characters.
*/ */
public enum SaveCause { @Accessors(fluent = true)
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public static class SaveCause implements Cause {
/** /**
* Indicates data saved when a player disconnected from the server (either to change servers, or to log off) * Indicates data saved when a player disconnected from the server (either to change servers, or to log off)
* *
* @since 2.0 * @since 2.0
*/ */
DISCONNECT, public static final SaveCause DISCONNECT = of("DISCONNECT");
/** /**
* Indicates data saved when the world saved * Indicates data saved when the world saved
* *
* @since 2.0 * @since 2.0
*/ */
WORLD_SAVE, public static final SaveCause WORLD_SAVE = of("WORLD_SAVE");
/** /**
* Indicates data saved when the user died * Indicates data saved when the user died
* *
* @since 2.1 * @since 2.1
*/ */
DEATH, public static final SaveCause DEATH = of("DEATH");
/** /**
* Indicates data saved when the server shut down * Indicates data saved when the server shut down
* *
* @since 2.0 * @since 2.0
*/ */
SERVER_SHUTDOWN, public static final SaveCause SERVER_SHUTDOWN = of("SERVER_SHUTDOWN", false);
/** /**
* Indicates data was saved by editing inventory contents via the {@code /inventory} command * Indicates data was saved by editing inventory contents via the {@code /inventory} command
* *
* @since 2.0 * @since 2.0
*/ */
INVENTORY_COMMAND, public static final SaveCause INVENTORY_COMMAND = of("INVENTORY_COMMAND");
/** /**
* Indicates data was saved by editing Ender Chest contents via the {@code /enderchest} command * Indicates data was saved by editing Ender Chest contents via the {@code /enderchest} command
* *
* @since 2.0 * @since 2.0
*/ */
ENDERCHEST_COMMAND, public static final SaveCause ENDERCHEST_COMMAND = of("ENDERCHEST_COMMAND");
/** /**
* Indicates data was saved by restoring it from a previous version * Indicates data was saved by restoring it from a previous version
* *
* @since 2.0 * @since 2.0
*/ */
BACKUP_RESTORE, public static final SaveCause BACKUP_RESTORE = of("BACKUP_RESTORE");
/** /**
* Indicates data was saved by an API call * Indicates data was saved by an API call
* *
* @since 2.0 * @since 2.0
*/ */
API, public static final SaveCause API = of("API");
/** /**
* Indicates data was saved from being imported from MySQLPlayerDataBridge * Indicates data was saved from being imported from MySQLPlayerDataBridge
* *
* @since 2.0 * @since 2.0
*/ */
MPDB_MIGRATION, public static final SaveCause MPDB_MIGRATION = of("MPDB_MIGRATION", false);
/** /**
* Indicates data was saved from being imported from a legacy version (v1.x -> v2.x) * Indicates data was saved from being imported from a legacy version (v1.x -> v2.x)
* *
* @since 2.0 * @since 2.0
*/ */
LEGACY_MIGRATION, public static final SaveCause LEGACY_MIGRATION = of("LEGACY_MIGRATION", false);
/** /**
* Indicates data was saved from being imported from a legacy version (v2.x -> v3.x) * Indicates data was saved from being imported from a legacy version (v2.x -> v3.x)
* *
* @since 3.0 * @since 3.0
*/ */
CONVERTED_FROM_V2; public static final SaveCause CONVERTED_FROM_V2 = of("CONVERTED_FROM_V2", false);
@NotNull @NotNull
public String getDisplayName() { private final String name;
return Locales.truncate(name().toLowerCase(Locale.ENGLISH)
.replaceAll("_", " "), 18); private final boolean fireDataSaveEvent;
private static Map<String, SaveCause> registry;
/**
* Get or create a {@link SaveCause} from a name
*
* @param name the name to be displayed
* @return the cause
*/
@NotNull
public static SaveCause of(@NotNull String name) {
return of(name,true);
} }
/**
* Get or create a {@link SaveCause} from a name and whether it should fire a save event
*
* @param name the name to be displayed
* @param firesSaveEvent whether the cause should fire a save event
* @return the cause
*/
@NotNull
public static SaveCause of(@NotNull String name, boolean firesSaveEvent) {
name = name.length() > 32 ? name.substring(0, 31) : name;
if (registry == null) registry = new HashMap<>();
if (registry.containsKey(name)) return registry.get(name);
SaveCause cause = new SaveCause(name, firesSaveEvent);
registry.put(cause.name(), cause);
return cause;
}
@NotNull
public String getLocale(@NotNull HuskSync plugin) {
return plugin.getLocales()
.getRawLocale("save_cause_%s".formatted(name().toLowerCase(Locale.ENGLISH)))
.orElse(getDisplayName());
}
@NotNull
@ApiStatus.Obsolete
public static SaveCause[] values() {
if (registry == null) registry = new HashMap<>();
return registry.values().toArray(new SaveCause[0]);
}
} }
/** /**
* Represents the cause of a player having their data updated. * A string wrapper, for identifying the cause of a player data update.
* </p>
* Cause names have a max length of 32 characters.
*/ */
public enum UpdateCause { @Accessors(fluent = true)
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public static class UpdateCause implements Cause {
/** /**
* Indicates the data was updated by a synchronization process * Indicates the data was updated by a synchronization process
* *
* @since 3.0 * @since 3.0
*/ */
SYNCHRONIZED("synchronization_complete", "synchronization_failed"), public static final UpdateCause SYNCHRONIZED = of("SYNCHRONIZED",
"synchronization_complete", "synchronization_failed"
);
/** /**
* Indicates the data was updated by a user joining the server * Indicates the data was updated by a user joining the server
* *
* @since 3.0 * @since 3.0
*/ */
NEW_USER("user_registration_complete", null), public static final UpdateCause NEW_USER = of("NEW_USER",
"user_registration_complete", null
);
/** /**
* Indicates the data was updated by a data update process (management command, API, etc.) * Indicates the data was updated by a data update process (management command, API, etc.)
* *
* @since 3.0 * @since 3.0
*/ */
UPDATED("data_update_complete", "data_update_failed"); public static final UpdateCause UPDATED = of("UPDATED",
"data_update_complete", "data_update_failed"
);
private final String completedLocale; /**
private final String failureLocale; * Indicates data was saved by an API call
*
* @since 3.3.3
*/
public static final UpdateCause API = of("API");
UpdateCause(@Nullable String completedLocale, @Nullable String failureLocale) { @NotNull
this.completedLocale = completedLocale; private final String name;
this.failureLocale = failureLocale; @Nullable
private String completedLocale;
@Nullable
private String failureLocale;
/**
* Get or create a {@link UpdateCause} from a name and completed/failure locales
*
* @param name the name to be displayed
* @param completedLocale the locale to be displayed on successful update,
* or {@code null} if none is to be shown
* @param failureLocale the locale to be displayed on a failed update,
* or {@code null} if none is to be shown
* @return the cause
*/
public static UpdateCause of(@NotNull String name, @Nullable String completedLocale,
@Nullable String failureLocale) {
return new UpdateCause(
name.length() > 32 ? name.substring(0, 31) : name,
completedLocale, failureLocale
);
} }
/**
* Get or create a {@link UpdateCause} from a name
*
* @param name the name to be displayed
* @return the cause
*/
@NotNull
public static UpdateCause of(@NotNull String name) {
return of(name, null, null);
}
/**
* Get the message to be displayed when a user's data is successfully updated.
*
* @param plugin plugin instance
* @return the message
*/
public Optional<MineDown> getCompletedLocale(@NotNull HuskSync plugin) { public Optional<MineDown> getCompletedLocale(@NotNull HuskSync plugin) {
if (completedLocale != null) { if (completedLocale() != null) {
return plugin.getLocales().getLocale(completedLocale); return Optional.of(plugin.getLocales().getLocale(completedLocale())
.orElse(plugin.getLocales().format(getDisplayName())));
} }
return Optional.empty(); return Optional.empty();
} }
/**
* Get the message to be displayed when a user's data fails to update.
*
* @param plugin plugin instance
* @return the message
*/
public Optional<MineDown> getFailedLocale(@NotNull HuskSync plugin) { public Optional<MineDown> getFailedLocale(@NotNull HuskSync plugin) {
if (failureLocale != null) { if (failureLocale() != null) {
return plugin.getLocales().getLocale(failureLocale); return Optional.of(plugin.getLocales().getLocale(failureLocale())
.orElse(plugin.getLocales().format(failureLocale())));
} }
return Optional.empty(); return Optional.empty();
} }
@NotNull
public static UpdateCause[] values() {
return new UpdateCause[]{
SYNCHRONIZED, NEW_USER, UPDATED
};
}
} }
} }

View File

@@ -19,38 +19,84 @@
package net.william278.husksync.data; package net.william278.husksync.data;
import lombok.*;
import net.kyori.adventure.key.InvalidKeyException; import net.kyori.adventure.key.InvalidKeyException;
import net.kyori.adventure.key.Key; import net.kyori.adventure.key.Key;
import org.intellij.lang.annotations.Subst; import org.intellij.lang.annotations.Subst;
import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collections;
import java.util.Comparator;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.stream.Stream; import java.util.stream.Stream;
/** /**
* Identifiers of different types of {@link Data}s * Identifiers of different types of {@link Data}s
*/ */
@Getter
public class Identifier { public class Identifier {
public static Identifier INVENTORY = huskSync("inventory", true); // Built-in identifiers
public static Identifier ENDER_CHEST = huskSync("ender_chest", true); public static final Identifier PERSISTENT_DATA = huskSync("persistent_data", true);
public static Identifier POTION_EFFECTS = huskSync("potion_effects", true); public static final Identifier INVENTORY = huskSync("inventory", true);
public static Identifier ADVANCEMENTS = huskSync("advancements", true); public static final Identifier ENDER_CHEST = huskSync("ender_chest", true);
public static Identifier LOCATION = huskSync("location", false); public static final Identifier ADVANCEMENTS = huskSync("advancements", true);
public static Identifier STATISTICS = huskSync("statistics", true); public static final Identifier STATISTICS = huskSync("statistics", true);
public static Identifier HEALTH = huskSync("health", true); public static final Identifier POTION_EFFECTS = huskSync("potion_effects", true);
public static Identifier HUNGER = huskSync("hunger", true); public static final Identifier GAME_MODE = huskSync("game_mode", true);
public static Identifier EXPERIENCE = huskSync("experience", true); public static final Identifier FLIGHT_STATUS = huskSync("flight_status", true,
public static Identifier GAME_MODE = huskSync("game_mode", true); Dependency.optional("game_mode")
public static Identifier PERSISTENT_DATA = huskSync("persistent_data", true); );
public static final Identifier ATTRIBUTES = huskSync("attributes", true,
Dependency.optional("inventory"),
Dependency.optional("potion_effects")
);
public static final Identifier HEALTH = huskSync("health", true,
Dependency.optional("attributes")
);
public static final Identifier HUNGER = huskSync("hunger", true,
Dependency.optional("attributes")
);
public static final Identifier EXPERIENCE = huskSync("experience", true,
Dependency.optional("advancements")
);
public static final Identifier LOCATION = huskSync("location", false,
Dependency.optional("flight_status"),
Dependency.optional("potion_effects")
);
private final Key key; private final Key key;
private final boolean configDefault; private final boolean enabledByDefault;
@Getter
private final Set<Dependency> dependencies;
@Setter
@Getter
public boolean enabled;
private Identifier(@NotNull Key key, boolean configDefault) { private Identifier(@NotNull Key key, boolean enabledByDefault, @NotNull Set<Dependency> dependencies) {
this.key = key; this.key = key;
this.configDefault = configDefault; this.enabledByDefault = enabledByDefault;
this.enabled = enabledByDefault;
this.dependencies = dependencies;
}
/**
* Create an identifier from a {@link Key}
*
* @param key the key
* @param dependencies the dependencies
* @return the identifier
* @since 3.5.4
*/
@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!");
}
return new Identifier(key, true, dependencies);
} }
/** /**
@@ -62,10 +108,7 @@ public class Identifier {
*/ */
@NotNull @NotNull
public static Identifier from(@NotNull Key key) { public static Identifier from(@NotNull Key key) {
if (key.namespace().equals("husksync")) { return from(key, Collections.emptySet());
throw new IllegalArgumentException("You cannot register a key with \"husksync\" as the namespace!");
}
return new Identifier(key, true);
} }
/** /**
@@ -81,25 +124,34 @@ public class Identifier {
return from(Key.key(plugin, name)); return from(Key.key(plugin, name));
} }
/**
* Create an identifier from a namespace, value, and dependencies
*
* @param plugin the namespace
* @param name the value
* @param dependencies the dependencies
* @return the identifier
* @since 3.5.4
*/
@NotNull
public static Identifier from(@Subst("plugin") @NotNull String plugin, @Subst("null") @NotNull String name,
@NotNull Set<Dependency> dependencies) {
return from(Key.key(plugin, name), dependencies);
}
// Return an identifier with a HuskSync namespace
@NotNull @NotNull
private static Identifier huskSync(@Subst("null") @NotNull String name, private static Identifier huskSync(@Subst("null") @NotNull String name,
boolean configDefault) throws InvalidKeyException { boolean configDefault) throws InvalidKeyException {
return new Identifier(Key.key("husksync", name), configDefault); return new Identifier(Key.key("husksync", name), configDefault, Collections.emptySet());
} }
// Return an identifier with a HuskSync namespace
@NotNull @NotNull
@SuppressWarnings("unused") private static Identifier huskSync(@Subst("null") @NotNull String name,
private static Identifier parse(@NotNull String key) throws InvalidKeyException { @SuppressWarnings("SameParameterValue") boolean configDefault,
return huskSync(key, true); @NotNull Dependency... dependents) throws InvalidKeyException {
} return new Identifier(Key.key("husksync", name), configDefault, Set.of(dependents));
public boolean isEnabledByDefault() {
return configDefault;
}
@NotNull
private Map.Entry<String, Boolean> getConfigEntry() {
return Map.entry(getKeyValue(), configDefault);
} }
/** /**
@@ -113,13 +165,24 @@ public class Identifier {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public static Map<String, Boolean> getConfigMap() { public static Map<String, Boolean> getConfigMap() {
return Map.ofEntries(Stream.of( return Map.ofEntries(Stream.of(
INVENTORY, ENDER_CHEST, POTION_EFFECTS, ADVANCEMENTS, LOCATION, INVENTORY, ENDER_CHEST, POTION_EFFECTS, ADVANCEMENTS, LOCATION, STATISTICS,
STATISTICS, HEALTH, HUNGER, EXPERIENCE, GAME_MODE, PERSISTENT_DATA HEALTH, HUNGER, ATTRIBUTES, EXPERIENCE, GAME_MODE, FLIGHT_STATUS, PERSISTENT_DATA
) )
.map(Identifier::getConfigEntry) .map(Identifier::getConfigEntry)
.toArray(Map.Entry[]::new)); .toArray(Map.Entry[]::new));
} }
/**
* Returns {@code true} if the identifier depends on the given identifier
*
* @param identifier the identifier to check
* @return {@code true} if the identifier depends on the given identifier
* @since 3.5.4
*/
public boolean dependsOn(@NotNull Identifier identifier) {
return dependencies.contains(Dependency.required(identifier.key));
}
/** /**
* Get the namespace of the identifier * Get the namespace of the identifier
* *
@@ -167,11 +230,89 @@ public class Identifier {
* @return {@code true} if the given object is an identifier with the same key as this identifier * @return {@code true} if the given object is an identifier with the same key as this identifier
*/ */
@Override @Override
public boolean equals(Object obj) { public boolean equals(@Nullable Object obj) {
if (obj instanceof Identifier other) { return obj instanceof Identifier other ? toString().equals(other.toString()) : super.equals(obj);
return key.equals(other.key); }
// Get the config entry for the identifier
@NotNull
private Map.Entry<String, Boolean> getConfigEntry() {
return Map.entry(getKeyValue(), enabledByDefault);
}
/**
* Compares two identifiers based on their dependencies.
* <p>
* If this identifier contains a dependency on the other, it should come after & vice versa
*
* @since 3.5.4
*/
@NoArgsConstructor(access = AccessLevel.PACKAGE)
static class DependencyOrderComparator implements Comparator<Identifier> {
@Override
public int compare(@NotNull Identifier i1, @NotNull Identifier i2) {
if (i1.equals(i2)) {
return 0;
}
if (i1.dependsOn(i2)) {
if (i2.dependsOn(i1)) {
throw new IllegalArgumentException(
"Found circular dependency between %s and %s".formatted(i1.getKey(), i2.getKey())
);
}
return 1;
}
return -1;
}
}
/**
* Represents a data dependency of an identifier, used to determine the order in which data is applied to users
*
* @since 3.5.4
*/
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public static class Dependency {
/**
* Key of the data dependency see {@code Identifier#key()}
*/
private Key key;
/**
* Whether the data dependency is required to be present & enabled for the dependant data to enabled
*/
private boolean required;
@NotNull
protected static Dependency required(@NotNull Key identifier) {
return new Dependency(identifier, true);
}
@NotNull
public static Dependency optional(@NotNull Key identifier) {
return new Dependency(identifier, false);
}
@NotNull
@SuppressWarnings("SameParameterValue")
private static Dependency required(@Subst("null") @NotNull String identifier) {
return required(Key.key("husksync", identifier));
}
@NotNull
private static Dependency optional(@Subst("null") @NotNull String identifier) {
return optional(Key.key("husksync", identifier));
}
@Override
public boolean equals(Object obj) {
if (obj instanceof Dependency other) {
return key.equals(other.key);
}
return false;
} }
return false;
} }
} }

View File

@@ -19,26 +19,55 @@
package net.william278.husksync.data; package net.william278.husksync.data;
import net.william278.desertwell.util.Version;
import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.Adaptable;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
public interface Serializer<T extends Data> { public interface Serializer<T extends Data> {
T deserialize(@NotNull String serialized) throws DeserializationException; T deserialize(@NotNull String serialized);
default T deserialize(@NotNull String serialized, @NotNull Version dataMcVersion) throws DeserializationException {
return deserialize(serialized);
}
@NotNull @NotNull
String serialize(@NotNull T element) throws SerializationException; String serialize(@NotNull T element) throws SerializationException;
static final class DeserializationException extends IllegalStateException { final class DeserializationException extends IllegalStateException {
DeserializationException(@NotNull String message, @NotNull Throwable cause) { DeserializationException(@NotNull String message, @NotNull Throwable cause) {
super(message, cause); super(message, cause);
} }
} }
static final class SerializationException extends IllegalStateException { final class SerializationException extends IllegalStateException {
SerializationException(@NotNull String message, @NotNull Throwable cause) { SerializationException(@NotNull String message, @NotNull Throwable cause) {
super(message, cause); super(message, cause);
} }
} }
class Json<T extends Data & Adaptable> implements Serializer<T> {
private final HuskSync plugin;
private final Class<T> type;
public Json(@NotNull HuskSync plugin, @NotNull Class<T> type) {
this.type = type;
this.plugin = plugin;
}
@Override
public T deserialize(@NotNull String serialized) throws DeserializationException {
return plugin.getDataAdapter().fromJson(serialized, type);
}
@NotNull
@Override
public String serialize(@NotNull T element) throws SerializationException {
return plugin.getDataAdapter().toJson(element);
}
}
} }

View File

@@ -0,0 +1,174 @@
/*
* 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.Version;
import net.william278.husksync.HuskSync;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.*;
import java.util.logging.Level;
public interface SerializerRegistry {
// Comparator for ordering identifiers based on dependency
@NotNull
@ApiStatus.Internal
Comparator<Identifier> DEPENDENCY_ORDER_COMPARATOR = new Identifier.DependencyOrderComparator();
/**
* Returns the data serializer for the given {@link Identifier}
*
* @since 3.0
*/
@NotNull
<T extends Data> TreeMap<Identifier, Serializer<T>> getSerializers();
/**
* Register a data serializer for the given {@link Identifier}
*
* @param id the {@link Identifier}
* @param serializer the {@link Serializer}
* @since 3.0
*/
@SuppressWarnings("unchecked")
default void registerSerializer(@NotNull Identifier id, @NotNull Serializer<? extends Data> serializer) {
if (id.isCustom()) {
getPlugin().log(Level.INFO, "Registered custom data type: %s".formatted(id));
}
id.setEnabled(id.isCustom() || getPlugin().getSettings().getSynchronization().isFeatureEnabled(id));
getSerializers().put(id, (Serializer<Data>) serializer);
}
/**
* Ensure dependencies for identifiers that have required dependencies are met
* <p>
* This checks the dependencies of all registered identifiers and throws an {@link IllegalStateException}
* if a dependency has not been registered or enabled via the config
*
* @since 3.5.4
*/
default void validateDependencies() throws IllegalStateException {
getSerializers().keySet().stream().filter(Identifier::isEnabled)
.forEach(identifier -> {
final List<String> unmet = identifier.getDependencies().stream()
.filter(Identifier.Dependency::isRequired)
.filter(dep -> !isDataTypeAvailable(dep.getKey().asString()))
.map(dep -> dep.getKey().asString()).toList();
if (!unmet.isEmpty()) {
identifier.setEnabled(false);
getPlugin().log(Level.WARNING, "Disabled %s syncing as the following types need to be on: %s"
.formatted(identifier, String.join(", ", unmet)));
}
});
}
/**
* Get the {@link Identifier} for the given key
*
* @since 3.0
*/
default Optional<Identifier> getIdentifier(@NotNull String key) {
return getSerializers().keySet().stream()
.filter(id -> id.getKey().asString().equals(key)).findFirst();
}
/**
* Get a data serializer for the given {@link Identifier}
*
* @param identifier the {@link Identifier} to get the serializer for
* @return the {@link Serializer} for the given {@link Identifier}
* @since 3.5.4
*/
default Optional<Serializer<Data>> getSerializer(@NotNull Identifier identifier) {
return getSerializers().entrySet().stream()
.filter(entry -> entry.getKey().getKey().equals(identifier.getKey()))
.map(Map.Entry::getValue).findFirst();
}
/**
* Serialize data for the given {@link Identifier}
*
* @param identifier the {@link Identifier} to serialize data for
* @param data the data to serialize
* @return the serialized data
* @throws IllegalArgumentException if no serializer is found for the given {@link Identifier}
* @since 3.5.4
*/
@NotNull
default String serializeData(@NotNull Identifier identifier, @NotNull Data data) throws IllegalStateException {
return getSerializer(identifier).map(serializer -> serializer.serialize(data))
.orElseThrow(() -> new IllegalStateException("No serializer found for %s".formatted(identifier)));
}
/**
* Deserialize data of a given {@link Version Minecraft version} for the given {@link Identifier data identifier}
*
* @param identifier the {@link Identifier} to deserialize data for
* @param data the data to deserialize
* @param dataMcVersion the Minecraft version of the data
* @return the deserialized data
* @throws IllegalStateException if no serializer is found for the given {@link Identifier}
* @since 3.6.4
*/
@NotNull
default Data deserializeData(@NotNull Identifier identifier, @NotNull String data,
@NotNull Version dataMcVersion) throws IllegalStateException {
return getSerializer(identifier).map(serializer -> serializer.deserialize(data, dataMcVersion)).orElseThrow(
() -> new IllegalStateException("No serializer found for %s".formatted(identifier))
);
}
/**
* Deserialize data for the given {@link Identifier data identifier}
*
* @param identifier the {@link Identifier} to deserialize data for
* @param data the data to deserialize
* @return the deserialized data
* @since 3.5.4
* @deprecated Use {@link #deserializeData(Identifier, String, Version)} instead
*/
@NotNull
@Deprecated(since = "3.6.5")
default Data deserializeData(@NotNull Identifier identifier, @NotNull String data) {
return deserializeData(identifier, data, getPlugin().getMinecraftVersion());
}
/**
* Get the set of registered data types
*
* @return the set of registered data types
* @since 3.0
*/
@NotNull
default Set<Identifier> getRegisteredDataTypes() {
return getSerializers().keySet();
}
// Returns if a data type is available and enabled in the config
private boolean isDataTypeAvailable(@NotNull String key) {
return getIdentifier(key).map(Identifier::isEnabled).orElse(false);
}
@NotNull
HuskSync getPlugin();
}

View File

@@ -34,7 +34,7 @@ import java.util.logging.Level;
public interface UserDataHolder extends DataHolder { public interface UserDataHolder extends DataHolder {
/** /**
* Get the data that is enabled for syncing in the config * Get the data enabled for syncing in the config
* *
* @return the data that is enabled for syncing * @return the data that is enabled for syncing
* @since 3.0 * @since 3.0
@@ -43,7 +43,7 @@ public interface UserDataHolder extends DataHolder {
@NotNull @NotNull
default Map<Identifier, Data> getData() { default Map<Identifier, Data> getData() {
return getPlugin().getRegisteredDataTypes().stream() return getPlugin().getRegisteredDataTypes().stream()
.filter(type -> type.isCustom() || getPlugin().getSettings().isSyncFeatureEnabled(type)) .filter(Identifier::isEnabled)
.map(id -> Map.entry(id, getData(id))) .map(id -> Map.entry(id, getData(id)))
.filter(data -> data.getValue().isPresent()) .filter(data -> data.getValue().isPresent())
.collect(HashMap::new, (map, data) -> map.put(data.getKey(), data.getValue().get()), HashMap::putAll); .collect(HashMap::new, (map, data) -> map.put(data.getKey(), data.getValue().get()), HashMap::putAll);
@@ -60,7 +60,7 @@ public interface UserDataHolder extends DataHolder {
*/ */
@Override @Override
default void setData(@NotNull Identifier identifier, @NotNull Data data) { default void setData(@NotNull Identifier identifier, @NotNull Data data) {
getPlugin().runSync(() -> data.apply(this, getPlugin())); getPlugin().runSync(() -> data.apply(this, getPlugin()), this);
} }
/** /**
@@ -79,7 +79,8 @@ public interface UserDataHolder extends DataHolder {
* Deserialize and apply a data snapshot to this data owner * Deserialize and apply a data snapshot to this data owner
* <p> * <p>
* This method will deserialize the data on the current thread, then synchronously apply it on * This method will deserialize the data on the current thread, then synchronously apply it on
* the main server thread. * the main server thread. The order data will be applied is determined based on the dependencies of
* each data type (see {@link Identifier.Dependency}).
* </p> * </p>
* The {@code runAfter} callback function will be run after the snapshot has been applied. * The {@code runAfter} callback function will be run after the snapshot has been applied.
* *
@@ -97,6 +98,7 @@ public interface UserDataHolder extends DataHolder {
unpacked = snapshot.unpack(plugin); unpacked = snapshot.unpack(plugin);
} catch (Throwable e) { } catch (Throwable e) {
plugin.log(Level.SEVERE, String.format("Failed to unpack data snapshot for %s", getUsername()), e); plugin.log(Level.SEVERE, String.format("Failed to unpack data snapshot for %s", getUsername()), e);
runAfter.accept(false);
return; return;
} }
@@ -105,12 +107,15 @@ public interface UserDataHolder extends DataHolder {
try { try {
for (Map.Entry<Identifier, Data> entry : unpacked.getData().entrySet()) { for (Map.Entry<Identifier, Data> entry : unpacked.getData().entrySet()) {
final Identifier identifier = entry.getKey(); final Identifier identifier = entry.getKey();
if (plugin.getSettings().isSyncFeatureEnabled(identifier)) { if (!identifier.isEnabled()) {
if (identifier.isCustom()) { continue;
getCustomDataStore().put(identifier, entry.getValue());
}
entry.getValue().apply(this, plugin);
} }
// Apply the identified data
if (identifier.isCustom()) {
getCustomDataStore().put(identifier, entry.getValue());
}
entry.getValue().apply(this, plugin);
} }
} catch (Throwable e) { } catch (Throwable e) {
plugin.log(Level.SEVERE, String.format("Failed to apply data snapshot to %s", getUsername()), e); plugin.log(Level.SEVERE, String.format("Failed to apply data snapshot to %s", getUsername()), e);
@@ -118,7 +123,7 @@ public interface UserDataHolder extends DataHolder {
return; return;
} }
plugin.runAsync(() -> runAfter.accept(true)); plugin.runAsync(() -> runAfter.accept(true));
}); }, this);
} }
@Override @Override
@@ -171,6 +176,11 @@ public interface UserDataHolder extends DataHolder {
this.setData(Identifier.GAME_MODE, gameMode); this.setData(Identifier.GAME_MODE, gameMode);
} }
@Override
default void setFlightStatus(@NotNull Data.FlightStatus flightStatus) {
this.setData(Identifier.FLIGHT_STATUS, flightStatus);
}
@Override @Override
default void setPersistentData(@NotNull Data.PersistentData persistentData) { default void setPersistentData(@NotNull Data.PersistentData persistentData) {
this.setData(Identifier.PERSISTENT_DATA, persistentData); this.setData(Identifier.PERSISTENT_DATA, persistentData);

View File

@@ -19,11 +19,10 @@
package net.william278.husksync.database; package net.william278.husksync.database;
import lombok.Getter;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
import net.william278.husksync.config.Settings; import net.william278.husksync.config.Settings;
import net.william278.husksync.data.DataSnapshot; 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 net.william278.husksync.user.User;
import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
@@ -31,10 +30,8 @@ import org.jetbrains.annotations.NotNull;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List; import java.util.*;
import java.util.Objects; import java.util.function.BiConsumer;
import java.util.Optional;
import java.util.UUID;
/** /**
* An abstract representation of the plugin database, storing player data. * An abstract representation of the plugin database, storing player data.
@@ -71,8 +68,9 @@ public abstract class Database {
*/ */
@NotNull @NotNull
protected final String formatStatementTables(@NotNull String sql) { protected final String formatStatementTables(@NotNull String sql) {
return sql.replaceAll("%users_table%", plugin.getSettings().getTableName(Settings.TableName.USERS)) final Settings.DatabaseSettings settings = plugin.getSettings().getDatabase();
.replaceAll("%user_data_table%", plugin.getSettings().getTableName(Settings.TableName.USER_DATA)); return sql.replaceAll("%users_table%", settings.getTableName(TableName.USERS))
.replaceAll("%user_data_table%", settings.getTableName(TableName.USER_DATA));
} }
/** /**
@@ -109,6 +107,14 @@ public abstract class Database {
@Blocking @Blocking
public abstract Optional<User> getUserByName(@NotNull String username); public abstract Optional<User> getUserByName(@NotNull String username);
/**
* Get all users
*
* @return A list of all users
*/
@NotNull
@Blocking
public abstract List<User> getAllUsers();
/** /**
* Get the latest data snapshot for a user. * Get the latest data snapshot for a user.
@@ -157,43 +163,24 @@ public abstract class Database {
@Blocking @Blocking
public abstract boolean deleteSnapshot(@NotNull User user, @NotNull UUID versionUuid); public abstract boolean deleteSnapshot(@NotNull User user, @NotNull UUID versionUuid);
/**
* 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 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)
*/
@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);
}
/** /**
* <b>Internal</b> - Save user data to the database. This will: * Save user data to the database, doing the following (in order):
* <ol> * <ol>
* <li>Delete their most recent snapshot, if it was created before the backup frequency time</li> * <li>Delete their most recent snapshot, if it was created before the backup frequency time</li>
* <li>Create the snapshot</li> * <li>Create the snapshot</li>
* <li>Rotate snapshot backups</li> * <li>Rotate snapshot backups</li>
* </ol> * </ol>
* This is an expensive blocking method and should be run off the main thread.
* *
* @param user The user to add data for * @param user The user to add data for
* @param snapshot The {@link DataSnapshot} to set. * @param snapshot The {@link DataSnapshot} to set.
* @apiNote Prefer {@link net.william278.husksync.sync.DataSyncer#saveData(User, DataSnapshot.Packed, BiConsumer)}.
* </p>This method will not fire the {@link net.william278.husksync.event.DataSaveEvent}
*/ */
@Blocking @Blocking
private void addAndRotateSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed snapshot) { public void addSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed snapshot) {
final int backupFrequency = plugin.getSettings().getBackupFrequency(); final int backupFrequency = plugin.getSettings().getSynchronization().getSnapshotBackupFrequency();
if (!snapshot.isPinned() && backupFrequency > 0) { if (!snapshot.isPinned() && backupFrequency > 0) {
this.rotateLatestSnapshot(user, snapshot.getTimestamp().minusHours(backupFrequency)); this.rotateLatestSnapshot(user, snapshot.getTimestamp().minusHours(backupFrequency));
} }
@@ -274,9 +261,12 @@ public abstract class Database {
/** /**
* Identifies types of databases * Identifies types of databases
*/ */
@Getter
public enum Type { public enum Type {
MYSQL("MySQL", "mysql"), MYSQL("MySQL", "mysql"),
MARIADB("MariaDB", "mariadb"); MARIADB("MariaDB", "mariadb"),
POSTGRES("PostgreSQL", "postgresql"),
MONGO("MongoDB", "mongo");
private final String displayName; private final String displayName;
private final String protocol; private final String protocol;
@@ -285,16 +275,33 @@ public abstract class Database {
this.displayName = displayName; this.displayName = displayName;
this.protocol = protocol; this.protocol = protocol;
} }
@NotNull
public String getDisplayName() {
return displayName;
}
@NotNull
public String getProtocol() {
return protocol;
}
} }
/**
* Represents the names of tables in the database
*/
@Getter
public enum TableName {
USERS("husksync_users"),
USER_DATA("husksync_user_data");
private final String defaultName;
TableName(@NotNull String defaultName) {
this.defaultName = defaultName;
}
@NotNull
private Map.Entry<String, String> toEntry() {
return Map.entry(name().toLowerCase(Locale.ENGLISH), defaultName);
}
@SuppressWarnings("unchecked")
@NotNull
public static Map<String, String> getDefaults() {
return Map.ofEntries(Arrays.stream(values())
.map(TableName::toEntry)
.toArray(Map.Entry[]::new));
}
}
} }

View File

@@ -0,0 +1,364 @@
/*
* 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.database;
import com.google.common.collect.Lists;
import com.mongodb.ConnectionString;
import com.mongodb.MongoException;
import com.mongodb.client.FindIterable;
import com.mongodb.client.model.Updates;
import net.william278.husksync.HuskSync;
import net.william278.husksync.config.Settings;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.database.mongo.MongoCollectionHelper;
import net.william278.husksync.database.mongo.MongoConnectionHandler;
import net.william278.husksync.user.User;
import org.bson.Document;
import org.bson.conversions.Bson;
import org.bson.types.Binary;
import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.NotNull;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import java.util.TimeZone;
import java.util.UUID;
import java.util.logging.Level;
public class MongoDbDatabase extends Database {
private MongoConnectionHandler mongoConnectionHandler;
private MongoCollectionHelper mongoCollectionHelper;
private final String usersTable;
private final String userDataTable;
public MongoDbDatabase(@NotNull HuskSync plugin) {
super(plugin);
this.usersTable = plugin.getSettings().getDatabase().getTableName(TableName.USERS);
this.userDataTable = plugin.getSettings().getDatabase().getTableName(TableName.USER_DATA);
}
@Override
public void initialize() throws IllegalStateException {
final Settings.DatabaseSettings.DatabaseCredentials credentials = plugin.getSettings().getDatabase().getCredentials();
try {
ConnectionString URI = createConnectionURI(credentials);
mongoConnectionHandler = new MongoConnectionHandler(URI, credentials.getDatabase());
mongoCollectionHelper = new MongoCollectionHelper(mongoConnectionHandler);
// Check config for if tables should be created
if (!plugin.getSettings().getDatabase().isCreateTables()) return;
if (mongoCollectionHelper.getCollection(usersTable) == null) {
mongoCollectionHelper.createCollection(usersTable);
}
if (mongoCollectionHelper.getCollection(userDataTable) == null) {
mongoCollectionHelper.createCollection(userDataTable);
}
} catch (Exception e) {
throw new IllegalStateException("Failed to establish a connection to the MongoDB database. " +
"Please check the supplied database credentials in the config file", e);
}
}
@NotNull
private ConnectionString createConnectionURI(Settings.DatabaseSettings.DatabaseCredentials credentials) {
String baseURI = plugin.getSettings().getDatabase().getMongoSettings().isUsingAtlas() ?
"mongodb+srv://{0}:{1}@{2}/{4}{5}" : "mongodb://{0}:{1}@{2}:{3}/{4}{5}";
baseURI = baseURI.replace("{0}", credentials.getUsername());
baseURI = baseURI.replace("{1}", credentials.getPassword());
baseURI = baseURI.replace("{2}", credentials.getHost());
baseURI = baseURI.replace("{3}", String.valueOf(credentials.getPort()));
baseURI = baseURI.replace("{4}", credentials.getDatabase());
baseURI = baseURI.replace("{5}", plugin.getSettings().getDatabase().getMongoSettings().getParameters());
return new ConnectionString(baseURI);
}
@Blocking
@Override
public void ensureUser(@NotNull User user) {
try {
getUser(user.getUuid()).ifPresentOrElse(
existingUser -> {
if (!existingUser.getUsername().equals(user.getUsername())) {
// Update a user's name if it has changed in the database
try {
Document filter = new Document("uuid", existingUser.getUuid());
Document doc = mongoCollectionHelper.getCollection(usersTable).find(filter).first();
if (doc == null) {
throw new MongoException("User document returned null!");
}
Bson updates = Updates.set("username", user.getUsername());
mongoCollectionHelper.updateDocument(usersTable, doc, updates);
} catch (MongoException e) {
plugin.log(Level.SEVERE, "Failed to insert a user into the database", e);
}
}
},
() -> {
// Insert new player data into the database
try {
Document doc = new Document("uuid", user.getUuid()).append("username", user.getUsername());
mongoCollectionHelper.insertDocument(usersTable, doc);
} catch (MongoException e) {
plugin.log(Level.SEVERE, "Failed to insert a user into the database", e);
}
}
);
} catch (MongoException e) {
plugin.log(Level.SEVERE, "Failed to ensure user data is in the database", e);
}
}
@Blocking
@Override
public Optional<User> getUser(@NotNull UUID uuid) {
try {
Document filter = new Document("uuid", uuid);
Document doc = mongoCollectionHelper.getCollection(usersTable).find(filter).first();
if (doc != null) {
return Optional.of(new User(uuid, doc.getString("username")));
}
return Optional.empty();
} catch (MongoException e) {
plugin.log(Level.SEVERE, "Failed to get user data from the database", e);
return Optional.empty();
}
}
@Blocking
@Override
public Optional<User> getUserByName(@NotNull String username) {
try {
Document filter = new Document("username", username);
Document doc = mongoCollectionHelper.getCollection(usersTable).find(filter).first();
if (doc != null) {
return Optional.of(new User(doc.get("uuid", UUID.class),
doc.getString("username")));
}
return Optional.empty();
} catch (MongoException e) {
plugin.log(Level.SEVERE, "Failed to get user data from the database", e);
return Optional.empty();
}
}
@Override
@NotNull
public List<User> getAllUsers() {
final List<User> users = Lists.newArrayList();
try {
final FindIterable<Document> doc = mongoCollectionHelper.getCollection(usersTable).find();
for (Document document : doc) {
users.add(new User(
UUID.fromString(document.getString("uuid")),
document.getString("username")
));
}
} catch (MongoException e) {
plugin.log(Level.SEVERE, "Failed to get all users from the database", e);
}
return users;
}
@Blocking
@Override
public Optional<DataSnapshot.Packed> getLatestSnapshot(@NotNull User user) {
try {
Document filter = new Document("player_uuid", user.getUuid());
Document sort = new Document("timestamp", -1); // -1 = Descending
FindIterable<Document> iterable = mongoCollectionHelper.getCollection(userDataTable).find(filter).sort(sort);
Document doc = iterable.first();
if (doc != null) {
final UUID versionUuid = doc.get("version_uuid", UUID.class);
final OffsetDateTime timestamp = OffsetDateTime.ofInstant(Instant.ofEpochMilli((long) doc.get("timestamp")), TimeZone.getDefault().toZoneId());
final Binary bin = doc.get("data", Binary.class);
final byte[] dataByteArray = bin.getData();
return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray, versionUuid, timestamp));
}
return Optional.empty();
} catch (MongoException e) {
plugin.log(Level.SEVERE, "Failed to get latest snapshot from the database", e);
return Optional.empty();
}
}
@Blocking
@Override
@NotNull
public List<DataSnapshot.Packed> getAllSnapshots(@NotNull User user) {
try {
final List<DataSnapshot.Packed> retrievedData = Lists.newArrayList();
Document filter = new Document("player_uuid", user.getUuid());
Document sort = new Document("timestamp", -1); // -1 = Descending
FindIterable<Document> iterable = mongoCollectionHelper.getCollection(userDataTable).find(filter).sort(sort);
for (Document doc : iterable) {
final UUID versionUuid = doc.get("version_uuid", UUID.class);
final OffsetDateTime timestamp = OffsetDateTime.ofInstant(Instant.ofEpochMilli((long) doc.get("timestamp")), TimeZone.getDefault().toZoneId());
final Binary bin = doc.get("data", Binary.class);
final byte[] dataByteArray = bin.getData();
retrievedData.add(DataSnapshot.deserialize(plugin, dataByteArray, versionUuid, timestamp));
}
return retrievedData;
} catch (MongoException e) {
plugin.log(Level.SEVERE, "Failed to get all snapshots from the database", e);
return Lists.newArrayList();
}
}
@Blocking
@Override
public Optional<DataSnapshot.Packed> getSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
try {
Document filter = new Document("player_uuid", user.getUuid()).append("version_uuid", versionUuid);
Document sort = new Document("timestamp", -1); // -1 = Descending
FindIterable<Document> iterable = mongoCollectionHelper.getCollection(userDataTable).find(filter).sort(sort);
Document doc = iterable.first();
if (doc != null) {
final OffsetDateTime timestamp = OffsetDateTime.ofInstant(Instant.ofEpochMilli((long) doc.get("timestamp")), TimeZone.getDefault().toZoneId());
final Binary bin = doc.get("data", Binary.class);
final byte[] dataByteArray = bin.getData();
return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray, versionUuid, timestamp));
}
return Optional.empty();
} catch (MongoException e) {
plugin.log(Level.SEVERE, "Failed to get snapshot from the database", e);
return Optional.empty();
}
}
@Blocking
@Override
protected void rotateSnapshots(@NotNull User user) {
try {
final List<DataSnapshot.Packed> unpinnedUserData = getAllSnapshots(user).stream()
.filter(dataSnapshot -> !dataSnapshot.isPinned()).toList();
final int maxSnapshots = plugin.getSettings().getSynchronization().getMaxUserDataSnapshots();
if (unpinnedUserData.size() > 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);
for (Document doc : iterable) {
mongoCollectionHelper.deleteDocument(userDataTable, doc);
}
}
} catch (MongoException e) {
plugin.log(Level.SEVERE, "Failed to rotate snapshots", e);
}
}
@Blocking
@Override
public boolean deleteSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
try {
Document filter = new Document("player_uuid", user.getUuid()).append("version_uuid", versionUuid);
Document doc = mongoCollectionHelper.getCollection(userDataTable).find(filter).first();
if (doc == null) {
return false;
}
mongoCollectionHelper.deleteDocument(userDataTable, doc);
return true;
} catch (MongoException 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 {
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);
for (Document doc : iterable) {
final OffsetDateTime timestamp = OffsetDateTime.ofInstant(
Instant.ofEpochMilli((long) doc.get("timestamp")), TimeZone.getDefault().toZoneId()
);
if (timestamp.isAfter(within)) {
mongoCollectionHelper.deleteDocument(userDataTable, doc);
return;
}
}
} catch (MongoException e) {
plugin.log(Level.SEVERE, "Failed to rotate latest snapshot from the database", e);
}
}
@Blocking
@Override
protected void createSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed data) {
try {
Document doc = new Document("player_uuid", user.getUuid())
.append("version_uuid", data.getId())
.append("timestamp", data.getTimestamp().toInstant().toEpochMilli())
.append("save_cause", data.getSaveCause().name())
.append("pinned", data.isPinned())
.append("data", new Binary(data.asBytes(plugin)));
mongoCollectionHelper.insertDocument(userDataTable, doc);
} catch (MongoException 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 {
Document doc = new Document("player_uuid", user.getUuid()).append("version_uuid", data.getId());
Bson updates = Updates.combine(
Updates.set("save_cause", data.getSaveCause().name()),
Updates.set("pinned", data.isPinned()),
Updates.set("data", new Binary(data.asBytes(plugin)))
);
mongoCollectionHelper.updateDocument(userDataTable, doc, updates);
} catch (MongoException e) {
plugin.log(Level.SEVERE, "Failed to update snapshot in the database", e);
}
}
@Blocking
@Override
public void wipeDatabase() {
try {
mongoCollectionHelper.deleteCollection(usersTable);
} catch (MongoException e) {
plugin.log(Level.SEVERE, "Failed to wipe the database", e);
}
}
@Override
public void terminate() {
if (mongoConnectionHandler != null) {
mongoConnectionHandler.closeConnection();
}
}
}

View File

@@ -19,6 +19,7 @@
package net.william278.husksync.database; package net.william278.husksync.database;
import com.google.common.collect.Lists;
import com.zaxxer.hikari.HikariDataSource; import com.zaxxer.hikari.HikariDataSource;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.DataAdapter; import net.william278.husksync.adapter.DataAdapter;
@@ -34,6 +35,8 @@ import java.time.OffsetDateTime;
import java.util.*; import java.util.*;
import java.util.logging.Level; import java.util.logging.Level;
import static net.william278.husksync.config.Settings.DatabaseSettings;
public class MySqlDatabase extends Database { public class MySqlDatabase extends Database {
private static final String DATA_POOL_NAME = "HuskSyncHikariPool"; private static final String DATA_POOL_NAME = "HuskSyncHikariPool";
@@ -43,9 +46,10 @@ public class MySqlDatabase extends Database {
public MySqlDatabase(@NotNull HuskSync plugin) { public MySqlDatabase(@NotNull HuskSync plugin) {
super(plugin); super(plugin);
this.flavor = plugin.getSettings().getDatabaseType().getProtocol();
this.driverClass = plugin.getSettings().getDatabaseType() == Type.MARIADB final Type type = plugin.getSettings().getDatabase().getType();
? "org.mariadb.jdbc.Driver" : "com.mysql.cj.jdbc.Driver"; this.flavor = type.getProtocol();
this.driverClass = type == Type.MARIADB ? "org.mariadb.jdbc.Driver" : "com.mysql.cj.jdbc.Driver";
} }
/** /**
@@ -67,26 +71,28 @@ public class MySqlDatabase extends Database {
@Override @Override
public void initialize() throws IllegalStateException { public void initialize() throws IllegalStateException {
// Initialize the Hikari pooled connection // Initialize the Hikari pooled connection
final DatabaseSettings.DatabaseCredentials credentials = plugin.getSettings().getDatabase().getCredentials();
dataSource = new HikariDataSource(); dataSource = new HikariDataSource();
dataSource.setDriverClassName(driverClass); dataSource.setDriverClassName(driverClass);
dataSource.setJdbcUrl(String.format("jdbc:%s://%s:%s/%s%s", dataSource.setJdbcUrl(String.format("jdbc:%s://%s:%s/%s%s",
flavor, flavor,
plugin.getSettings().getMySqlHost(), credentials.getHost(),
plugin.getSettings().getMySqlPort(), credentials.getPort(),
plugin.getSettings().getMySqlDatabase(), credentials.getDatabase(),
plugin.getSettings().getMySqlConnectionParameters() credentials.getParameters()
)); ));
// Authenticate with the database // Authenticate with the database
dataSource.setUsername(plugin.getSettings().getMySqlUsername()); dataSource.setUsername(credentials.getUsername());
dataSource.setPassword(plugin.getSettings().getMySqlPassword()); dataSource.setPassword(credentials.getPassword());
// Set connection pool options // Set connection pool options
dataSource.setMaximumPoolSize(plugin.getSettings().getMySqlConnectionPoolSize()); final DatabaseSettings.PoolSettings pool = plugin.getSettings().getDatabase().getConnectionPool();
dataSource.setMinimumIdle(plugin.getSettings().getMySqlConnectionPoolIdle()); dataSource.setMaximumPoolSize(pool.getMaximumPoolSize());
dataSource.setMaxLifetime(plugin.getSettings().getMySqlConnectionPoolLifetime()); dataSource.setMinimumIdle(pool.getMinimumIdle());
dataSource.setKeepaliveTime(plugin.getSettings().getMySqlConnectionPoolKeepAlive()); dataSource.setMaxLifetime(pool.getMaximumLifetime());
dataSource.setConnectionTimeout(plugin.getSettings().getMySqlConnectionPoolTimeout()); dataSource.setKeepaliveTime(pool.getKeepaliveTime());
dataSource.setConnectionTimeout(pool.getConnectionTimeout());
dataSource.setPoolName(DATA_POOL_NAME); dataSource.setPoolName(DATA_POOL_NAME);
// Set additional connection pool properties // Set additional connection pool properties
@@ -109,6 +115,9 @@ public class MySqlDatabase extends Database {
); );
dataSource.setDataSourceProperties(properties); dataSource.setDataSourceProperties(properties);
// Check config for if tables should be created
if (!plugin.getSettings().getDatabase().isCreateTables()) return;
// Prepare database schema; make tables if they don't exist // Prepare database schema; make tables if they don't exist
try (Connection connection = dataSource.getConnection()) { try (Connection connection = dataSource.getConnection()) {
final String[] databaseSchema = getSchemaStatements(String.format("database/%s_schema.sql", flavor)); final String[] databaseSchema = getSchemaStatements(String.format("database/%s_schema.sql", flavor));
@@ -118,11 +127,11 @@ public class MySqlDatabase extends Database {
} }
} catch (SQLException e) { } catch (SQLException e) {
throw new IllegalStateException("Failed to create database tables. Please ensure you are running MySQL v8.0+ " + throw new IllegalStateException("Failed to create database tables. Please ensure you are running MySQL v8.0+ " +
"and that your connecting user account has privileges to create tables.", e); "and that your connecting user account has privileges to create tables.", e);
} }
} catch (SQLException | IOException e) { } catch (SQLException | IOException e) {
throw new IllegalStateException("Failed to establish a connection to the MySQL database. " + throw new IllegalStateException("Failed to establish a connection to the MySQL database. " +
"Please check the supplied database credentials in the config file", e); "Please check the supplied database credentials in the config file", e);
} }
} }
@@ -212,6 +221,27 @@ public class MySqlDatabase extends Database {
return Optional.empty(); return Optional.empty();
} }
@Override
@NotNull
public List<User> getAllUsers() {
final List<User> users = Lists.newArrayList();
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `uuid`, `username`
FROM `%users_table%`;
"""))) {
final ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
users.add(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 users;
}
@Blocking @Blocking
@Override @Override
public Optional<DataSnapshot.Packed> getLatestSnapshot(@NotNull User user) { public Optional<DataSnapshot.Packed> getLatestSnapshot(@NotNull User user) {
@@ -245,7 +275,7 @@ public class MySqlDatabase extends Database {
@Override @Override
@NotNull @NotNull
public List<DataSnapshot.Packed> getAllSnapshots(@NotNull User user) { public List<DataSnapshot.Packed> getAllSnapshots(@NotNull User user) {
final List<DataSnapshot.Packed> retrievedData = new ArrayList<>(); final List<DataSnapshot.Packed> retrievedData = Lists.newArrayList();
try (Connection connection = getConnection()) { try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables(""" try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `data` SELECT `version_uuid`, `timestamp`, `data`
@@ -306,7 +336,8 @@ public class MySqlDatabase extends Database {
protected void rotateSnapshots(@NotNull User user) { protected void rotateSnapshots(@NotNull User user) {
final List<DataSnapshot.Packed> unpinnedUserData = getAllSnapshots(user).stream() final List<DataSnapshot.Packed> unpinnedUserData = getAllSnapshots(user).stream()
.filter(dataSnapshot -> !dataSnapshot.isPinned()).toList(); .filter(dataSnapshot -> !dataSnapshot.isPinned()).toList();
if (unpinnedUserData.size() > plugin.getSettings().getMaxUserDataSnapshots()) { final int maxSnapshots = plugin.getSettings().getSynchronization().getMaxUserDataSnapshots();
if (unpinnedUserData.size() > maxSnapshots) {
try (Connection connection = getConnection()) { try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables(""" try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
DELETE FROM `%user_data_table%` DELETE FROM `%user_data_table%`
@@ -314,7 +345,7 @@ public class MySqlDatabase extends Database {
AND `pinned` IS FALSE AND `pinned` IS FALSE
ORDER BY `timestamp` ASC ORDER BY `timestamp` ASC
LIMIT %entry_count%;""".replace("%entry_count%", LIMIT %entry_count%;""".replace("%entry_count%",
Integer.toString(unpinnedUserData.size() - plugin.getSettings().getMaxUserDataSnapshots()))))) { Integer.toString(unpinnedUserData.size() - maxSnapshots))))) {
statement.setString(1, user.getUuid().toString()); statement.setString(1, user.getUuid().toString());
statement.executeUpdate(); statement.executeUpdate();
} }

View File

@@ -0,0 +1,453 @@
/*
* 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.database;
import com.google.common.collect.Lists;
import com.zaxxer.hikari.HikariDataSource;
import net.william278.husksync.HuskSync;
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.IOException;
import java.sql.*;
import java.time.OffsetDateTime;
import java.util.*;
import java.util.logging.Level;
import static net.william278.husksync.config.Settings.DatabaseSettings;
public class PostgresDatabase extends Database {
private static final String DATA_POOL_NAME = "HuskSyncHikariPool";
private final String flavor;
private final String driverClass;
private HikariDataSource dataSource;
public PostgresDatabase(@NotNull HuskSync plugin) {
super(plugin);
final Type type = plugin.getSettings().getDatabase().getType();
this.flavor = type.getProtocol();
this.driverClass = "org.postgresql.Driver";
}
@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
final DatabaseSettings.DatabaseCredentials credentials = plugin.getSettings().getDatabase().getCredentials();
dataSource = new HikariDataSource();
dataSource.setDriverClassName(driverClass);
dataSource.setJdbcUrl(String.format("jdbc:%s://%s:%s/%s%s",
flavor,
credentials.getHost(),
credentials.getPort(),
credentials.getDatabase(),
credentials.getParameters()
));
// Authenticate with the database
dataSource.setUsername(credentials.getUsername());
dataSource.setPassword(credentials.getPassword());
// Set connection pool options
final DatabaseSettings.PoolSettings pool = plugin.getSettings().getDatabase().getConnectionPool();
dataSource.setMaximumPoolSize(pool.getMaximumPoolSize());
dataSource.setMinimumIdle(pool.getMinimumIdle());
dataSource.setMaxLifetime(pool.getMaximumLifetime());
dataSource.setKeepaliveTime(pool.getKeepaliveTime());
dataSource.setConnectionTimeout(pool.getConnectionTimeout());
dataSource.setPoolName(DATA_POOL_NAME);
// Set additional connection pool properties
final Properties properties = new Properties();
properties.putAll(
Map.of("cachePrepStmts", "true",
"prepStmtCacheSize", "250",
"prepStmtCacheSqlLimit", "2048",
"useServerPrepStmts", "true",
"useLocalSessionState", "true",
"useLocalTransactionState", "true"
));
properties.putAll(
Map.of(
"rewriteBatchedStatements", "true",
"cacheResultSetMetadata", "true",
"cacheServerConfiguration", "true",
"elideSetAutoCommits", "true",
"maintainTimeStats", "false")
);
dataSource.setDataSourceProperties(properties);
// Check config for if tables should be created
if (!plugin.getSettings().getDatabase().isCreateTables()) return;
// Prepare database schema; make tables if they don't exist
try (Connection connection = dataSource.getConnection()) {
final String[] databaseSchema = getSchemaStatements(String.format("database/%s_schema.sql", flavor));
try (Statement statement = connection.createStatement()) {
for (String tableCreationStatement : databaseSchema) {
statement.execute(tableCreationStatement);
}
} catch (SQLException e) {
throw new IllegalStateException("Failed to create database tables. Please ensure you are running PostgreSQL " +
"and that your connecting user account has privileges to create tables.", e);
}
} catch (SQLException | IOException e) {
throw new IllegalStateException("Failed to establish a connection to the PostgreSQL database. " +
"Please check the supplied database credentials in the config file", e);
}
}
@Blocking
@Override
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.getUsername());
statement.setObject(2, existingUser.getUuid());
statement.executeUpdate();
}
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.setObject(1, user.getUuid());
statement.setString(2, user.getUsername());
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to insert a user into the database", e);
}
}
);
}
@Blocking
@Override
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=?;"""))) {
statement.setObject(1, uuid);
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
return Optional.of(new User((UUID) resultSet.getObject("uuid"),
resultSet.getString("username")));
}
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user from uuid from the database", e);
}
return Optional.empty();
}
@Blocking
@Override
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) resultSet.getObject("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();
}
@Override
@NotNull
public List<User> getAllUsers() {
final List<User> users = Lists.newArrayList();
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT uuid, username
FROM %users_table%;
"""))) {
final ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
users.add(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 users;
}
@Blocking
@Override
public Optional<DataSnapshot.Packed> getLatestSnapshot(@NotNull User user) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT version_uuid, timestamp, data
FROM %user_data_table%
WHERE player_uuid=?
ORDER BY timestamp DESC
LIMIT 1;"""))) {
statement.setObject(1, user.getUuid());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
final UUID versionUuid = (UUID) resultSet.getObject("version_uuid");
final OffsetDateTime timestamp = OffsetDateTime.ofInstant(
resultSet.getTimestamp("timestamp").toInstant(), TimeZone.getDefault().toZoneId()
);
final byte[] dataByteArray = resultSet.getBytes("data");
return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray, versionUuid, timestamp));
}
}
} 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
@NotNull
public List<DataSnapshot.Packed> getAllSnapshots(@NotNull User user) {
final List<DataSnapshot.Packed> retrievedData = Lists.newArrayList();
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT version_uuid, timestamp, data
FROM %user_data_table%
WHERE player_uuid=?
ORDER BY timestamp DESC;"""))) {
statement.setObject(1, user.getUuid());
final ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
final UUID versionUuid = (UUID) resultSet.getObject("version_uuid");
final OffsetDateTime timestamp = OffsetDateTime.ofInstant(
resultSet.getTimestamp("timestamp").toInstant(), TimeZone.getDefault().toZoneId()
);
final byte[] dataByteArray = resultSet.getBytes("data");
retrievedData.add(DataSnapshot.deserialize(plugin, dataByteArray, versionUuid, timestamp));
}
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 retrievedData;
}
@Blocking
@Override
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, data
FROM %user_data_table%
WHERE player_uuid=? AND version_uuid=?
ORDER BY timestamp DESC
LIMIT 1;"""))) {
statement.setObject(1, user.getUuid());
statement.setObject(2, versionUuid);
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
final OffsetDateTime timestamp = OffsetDateTime.ofInstant(
resultSet.getTimestamp("timestamp").toInstant(), TimeZone.getDefault().toZoneId()
);
final byte[] dataByteArray = resultSet.getBytes("data");
return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray, versionUuid, timestamp));
}
}
} 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();
final int maxSnapshots = plugin.getSettings().getSynchronization().getMaxUserDataSnapshots();
if (unpinnedUserData.size() > maxSnapshots) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
WITH cte AS (
SELECT version_uuid
FROM %user_data_table%
WHERE player_uuid=?
AND pinned=FALSE
ORDER BY timestamp ASC
LIMIT %entry_count%
)
DELETE FROM %user_data_table%
WHERE version_uuid IN (SELECT version_uuid FROM cte);""".replace("%entry_count%",
Integer.toString(unpinnedUserData.size() - maxSnapshots))))) {
statement.setObject(1, user.getUuid());
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to prune user data from the database", e);
}
}
}
@Blocking
@Override
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=?;"""))) {
statement.setObject(1, user.getUuid());
statement.setObject(2, versionUuid);
return statement.executeUpdate() > 0;
}
} 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 = (
SELECT timestamp
FROM %user_data_table%
WHERE player_uuid=? AND timestamp > ? AND pinned=FALSE
ORDER BY timestamp ASC
LIMIT 1
);"""))) {
statement.setObject(1, user.getUuid());
statement.setObject(2, user.getUuid());
statement.setTimestamp(3, 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.setObject(1, user.getUuid());
statement.setObject(2, data.getId());
statement.setTimestamp(3, Timestamp.from(data.getTimestamp().toInstant()));
statement.setString(4, data.getSaveCause().name());
statement.setBoolean(5, data.isPinned());
statement.setBytes(6, 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.setBytes(3, data.asBytes(plugin));
statement.setObject(4, user.getUuid());
statement.setObject(5, data.getId());
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to pin user data in the database", e);
}
}
@Override
public void wipeDatabase() {
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 terminate() {
if (dataSource != null) {
if (!dataSource.isClosed()) {
dataSource.close();
}
}
}
}

View File

@@ -0,0 +1,100 @@
/*
* 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.database.mongo;
import com.mongodb.client.MongoCollection;
import org.bson.Document;
import org.bson.conversions.Bson;
import org.jetbrains.annotations.NotNull;
public class MongoCollectionHelper {
private final MongoConnectionHandler database;
/**
* Initialize the collection helper
*
* @param database Instance of {@link MongoConnectionHandler}
*/
public MongoCollectionHelper(@NotNull MongoConnectionHandler database) {
this.database = database;
}
/**
* Create a collection
*
* @param collectionName the collection name
*/
public void createCollection(@NotNull String collectionName) {
database.getDatabase().createCollection(collectionName);
}
/**
* Delete a collection
*
* @param collectionName the collection name
*/
public void deleteCollection(@NotNull String collectionName) {
database.getDatabase().getCollection(collectionName).drop();
}
/**
* Get a collection
*
* @param collectionName the collection name
* @return MongoCollection<Document>
*/
public MongoCollection<Document> getCollection(@NotNull String collectionName) {
return database.getDatabase().getCollection(collectionName);
}
/**
* Add a document to a collection
*
* @param collectionName collection to add to
* @param document Document to add
*/
public void insertDocument(@NotNull String collectionName, @NotNull Document document) {
MongoCollection<Document> collection = database.getDatabase().getCollection(collectionName);
collection.insertOne(document);
}
/**
* Update a document
*
* @param collectionName collection the document is in
* @param document filter of document
* @param updates Bson of updates
*/
public void updateDocument(@NotNull String collectionName, @NotNull Document document, @NotNull Bson updates) {
MongoCollection<Document> collection = database.getDatabase().getCollection(collectionName);
collection.updateOne(document, updates);
}
/**
* Delete a document
*
* @param collectionName collection the document is in
* @param document filter to remove
*/
public void deleteDocument(@NotNull String collectionName, @NotNull Document document) {
MongoCollection<Document> collection = database.getDatabase().getCollection(collectionName);
collection.deleteOne(document);
}
}

View File

@@ -0,0 +1,64 @@
/*
* 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.database.mongo;
import com.mongodb.ConnectionString;
import com.mongodb.MongoClientSettings;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoDatabase;
import lombok.Getter;
import org.bson.UuidRepresentation;
import org.jetbrains.annotations.NotNull;
@Getter
public class MongoConnectionHandler {
private final MongoClient mongoClient;
private final MongoDatabase database;
/**
* Initiate a connection to a Mongo Server
*
* @param uri The connection string
*/
public MongoConnectionHandler(@NotNull ConnectionString uri, @NotNull String databaseName) {
try {
final MongoClientSettings settings = MongoClientSettings.builder()
.applyConnectionString(uri)
.uuidRepresentation(UuidRepresentation.STANDARD)
.build();
this.mongoClient = MongoClients.create(settings);
this.database = mongoClient.getDatabase(databaseName);
} catch (Exception e) {
throw new IllegalStateException("Failed to establish a connection to the MongoDB database. " +
"Please check the supplied database credentials in the config file", e);
}
}
/**
* Close the connection with the database
*/
public void closeConnection() {
if (this.mongoClient != null) {
this.mongoClient.close();
}
}
}

View File

@@ -19,7 +19,6 @@
package net.william278.husksync.event; package net.william278.husksync.event;
@SuppressWarnings("unused")
public interface Cancellable extends Event { public interface Cancellable extends Event {
default boolean isCancelled() { default boolean isCancelled() {

View File

@@ -27,7 +27,7 @@ import org.jetbrains.annotations.NotNull;
import java.util.function.Consumer; import java.util.function.Consumer;
@SuppressWarnings("unused") @SuppressWarnings("unused")
public interface PreSyncEvent extends PlayerEvent { public interface PreSyncEvent extends PlayerEvent, Cancellable {
@NotNull @NotNull
DataSnapshot.Packed getData(); DataSnapshot.Packed getData();

View File

@@ -184,7 +184,7 @@ public class PlanHook {
public String getHealth(@NotNull UUID uuid) { public String getHealth(@NotNull UUID uuid) {
return getLatestSnapshot(uuid) return getLatestSnapshot(uuid)
.flatMap(DataHolder::getHealth) .flatMap(DataHolder::getHealth)
.map(health -> String.format("%s / %s", health.getHealth(), health.getMaxHealth())) .map(health -> String.format("%s", health.getHealth()))
.orElse(UNKNOWN_STRING); .orElse(UNKNOWN_STRING);
} }

View File

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

View File

@@ -0,0 +1,69 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.listener;
import net.william278.husksync.HuskSync;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
/**
* Interface for doing stuff with locked users or when the plugin is disabled
*/
public interface LockedHandler {
/**
* Get if a command should be disabled while the user is locked
*/
default boolean isCommandDisabled(@NotNull String label) {
final List<String> blocked = getPlugin().getSettings().getSynchronization().getBlacklistedCommandsWhileLocked();
return blocked.contains("*") || blocked.contains(label.toLowerCase(Locale.ENGLISH));
}
/**
* Determine whether a player event should be canceled
*
* @param userUuid The UUID of the user to check
* @return Whether the event should be canceled
*/
default boolean cancelPlayerEvent(@NotNull UUID userUuid) {
return getPlugin().isDisabling() || getPlugin().isLocked(userUuid);
}
@NotNull
@ApiStatus.Internal
HuskSync getPlugin();
default void onLoad() {
}
default void onEnable() {
}
default void onDisable() {
}
}

View File

@@ -24,15 +24,13 @@ import org.jetbrains.annotations.NotNull;
import java.util.Locale; import java.util.Locale;
public enum RedisKeyType { public enum RedisKeyType {
CACHE(60 * 60 * 24),
DATA_UPDATE(10),
SERVER_SWITCH(10);
private final int timeToLive; LATEST_SNAPSHOT,
SERVER_SWITCH,
DATA_CHECKOUT;
RedisKeyType(int timeToLive) { public static final int TTL_1_YEAR = 60 * 60 * 24 * 7 * 52; // 1 year
this.timeToLive = timeToLive; public static final int TTL_10_SECONDS = 10; // 10 seconds
}
@NotNull @NotNull
public String getKeyPrefix(@NotNull String clusterId) { public String getKeyPrefix(@NotNull String clusterId) {
@@ -44,8 +42,4 @@ public enum RedisKeyType {
); );
} }
public int getTimeToLive() {
return timeToLive;
}
} }

View File

@@ -20,18 +20,16 @@
package net.william278.husksync.redis; package net.william278.husksync.redis;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
import net.william278.husksync.config.Settings;
import net.william278.husksync.data.DataSnapshot; import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.User; import net.william278.husksync.user.User;
import org.jetbrains.annotations.Blocking; import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import redis.clients.jedis.Jedis; import redis.clients.jedis.*;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.JedisPubSub;
import redis.clients.jedis.exceptions.JedisException; import redis.clients.jedis.exceptions.JedisException;
import redis.clients.jedis.util.Pool;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.*; import java.util.*;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -39,17 +37,21 @@ import java.util.concurrent.TimeUnit;
import java.util.logging.Level; import java.util.logging.Level;
/** /**
* Manages the connection to the Redis server, handling the caching of user data * Manages the connection to Redis, handling the caching of user data
*/ */
public class RedisManager extends JedisPubSub { public class RedisManager extends JedisPubSub {
protected static final String KEY_NAMESPACE = "husksync:"; protected static final String KEY_NAMESPACE = "husksync:";
private static final int RECONNECTION_TIME = 8000;
private final HuskSync plugin; private final HuskSync plugin;
private final String clusterId; private final String clusterId;
private JedisPool jedisPool; private Pool<Jedis> jedisPool;
private final Map<UUID, CompletableFuture<Optional<DataSnapshot.Packed>>> pendingRequests; private final Map<UUID, CompletableFuture<Optional<DataSnapshot.Packed>>> pendingRequests;
private boolean enabled;
private boolean reconnected;
public RedisManager(@NotNull HuskSync plugin) { public RedisManager(@NotNull HuskSync plugin) {
this.plugin = plugin; this.plugin = plugin;
this.clusterId = plugin.getSettings().getClusterId(); this.clusterId = plugin.getSettings().getClusterId();
@@ -57,51 +59,98 @@ public class RedisManager extends JedisPubSub {
} }
/** /**
* Initialize the redis connection pool * Initialize Redis connection pool
*/ */
@Blocking @Blocking
public void initialize() throws IllegalStateException { public void initialize() throws IllegalStateException {
final String password = plugin.getSettings().getRedisPassword(); final Settings.RedisSettings.RedisCredentials credentials = plugin.getSettings().getRedis().getCredentials();
final String host = plugin.getSettings().getRedisHost(); final String password = credentials.getPassword();
final int port = plugin.getSettings().getRedisPort(); final String host = credentials.getHost();
final boolean useSSL = plugin.getSettings().redisUseSsl(); final int port = credentials.getPort();
final boolean useSSL = credentials.isUseSsl();
// Create the jedis pool // Create the jedis pool
final JedisPoolConfig config = new JedisPoolConfig(); final JedisPoolConfig config = new JedisPoolConfig();
config.setMaxIdle(0); config.setMaxIdle(0);
config.setTestOnBorrow(true); config.setTestOnBorrow(true);
config.setTestOnReturn(true); config.setTestOnReturn(true);
this.jedisPool = password.isEmpty()
? new JedisPool(config, host, port, 0, useSSL) final Settings.RedisSettings.RedisSentinel sentinel = plugin.getSettings().getRedis().getSentinel();
: new JedisPool(config, host, port, 0, password, useSSL); Set<String> redisSentinelNodes = new HashSet<>(sentinel.getNodes());
if (redisSentinelNodes.isEmpty()) {
this.jedisPool = password.isEmpty()
? new JedisPool(config, host, port, 0, useSSL)
: new JedisPool(config, host, port, 0, password, useSSL);
} else {
final String sentinelPassword = sentinel.getPassword();
this.jedisPool = new JedisSentinelPool(sentinel.getMaster(), redisSentinelNodes, password.isEmpty()
? null : password, sentinelPassword.isEmpty() ? null : sentinelPassword);
}
// Ping the server to check the connection // Ping the server to check the connection
try { try {
jedisPool.getResource().ping(); jedisPool.getResource().ping();
} catch (JedisException e) { } catch (JedisException e) {
throw new IllegalStateException("Failed to establish connection with the Redis server. " throw new IllegalStateException("Failed to establish connection with Redis. "
+ "Please check the supplied credentials in the config file", e); + "Please check the supplied credentials in the config file", e);
} }
// Subscribe using a thread (rather than a task) // Subscribe using a thread (rather than a task)
enabled = true;
new Thread(this::subscribe, "husksync:redis_subscriber").start(); new Thread(this::subscribe, "husksync:redis_subscriber").start();
} }
@Blocking @Blocking
private void subscribe() { private void subscribe() {
try (Jedis jedis = jedisPool.getResource()) { while (enabled && !Thread.interrupted() && jedisPool != null && !jedisPool.isClosed()) {
jedis.subscribe( try (Jedis jedis = jedisPool.getResource()) {
this, if (reconnected) {
Arrays.stream(RedisMessageType.values()) plugin.log(Level.INFO, "Redis connection is alive again");
.map(type -> type.getMessageChannel(clusterId)) }
.toArray(String[]::new) // Subscribe channels and lock the thread
); jedis.subscribe(
this,
Arrays.stream(RedisMessage.Type.values())
.map(type -> type.getMessageChannel(clusterId))
.toArray(String[]::new)
);
} catch (Throwable t) {
// Thread was unlocked due error
onThreadUnlock(t);
}
}
}
private void onThreadUnlock(@NotNull Throwable t) {
if (!enabled) {
return;
}
if (reconnected) {
plugin.log(Level.WARNING, "Redis Server connection lost. Attempting reconnect in %ss..."
.formatted(RECONNECTION_TIME / 1000), t);
}
try {
this.unsubscribe();
} catch (Throwable ignored) {
// empty catch
}
// Make an instant subscribe if occurs any error on initialization
if (!reconnected) {
reconnected = true;
} else {
try {
Thread.sleep(RECONNECTION_TIME);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
} }
} }
@Override @Override
public void onMessage(@NotNull String channel, @NotNull String message) { public void onMessage(@NotNull String channel, @NotNull String message) {
final RedisMessageType messageType = RedisMessageType.getTypeFromChannel(channel, clusterId).orElse(null); final RedisMessage.Type messageType = RedisMessage.Type.getTypeFromChannel(channel, clusterId).orElse(null);
if (messageType == null) { if (messageType == null) {
return; return;
} }
@@ -109,29 +158,51 @@ public class RedisManager extends JedisPubSub {
final RedisMessage redisMessage = RedisMessage.fromJson(plugin, message); final RedisMessage redisMessage = RedisMessage.fromJson(plugin, message);
switch (messageType) { switch (messageType) {
case UPDATE_USER_DATA -> plugin.getOnlineUser(redisMessage.getTargetUuid()).ifPresent( case UPDATE_USER_DATA -> plugin.getOnlineUser(redisMessage.getTargetUuid()).ifPresent(
user -> user.applySnapshot( user -> {
DataSnapshot.deserialize(plugin, redisMessage.getPayload()), plugin.lockPlayer(user.getUuid());
DataSnapshot.UpdateCause.UPDATED try {
) final DataSnapshot.Packed data = DataSnapshot.deserialize(plugin, redisMessage.getPayload());
user.applySnapshot(data, DataSnapshot.UpdateCause.UPDATED);
} catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred updating user data from Redis", e);
user.completeSync(false, DataSnapshot.UpdateCause.UPDATED, plugin);
}
}
); );
case REQUEST_USER_DATA -> plugin.getOnlineUser(redisMessage.getTargetUuid()).ifPresent( case REQUEST_USER_DATA -> plugin.getOnlineUser(redisMessage.getTargetUuid()).ifPresent(
user -> RedisMessage.create( user -> RedisMessage.create(
UUID.fromString(new String(redisMessage.getPayload(), StandardCharsets.UTF_8)), UUID.fromString(new String(redisMessage.getPayload(), StandardCharsets.UTF_8)),
user.createSnapshot(DataSnapshot.SaveCause.INVENTORY_COMMAND).asBytes(plugin) user.createSnapshot(DataSnapshot.SaveCause.INVENTORY_COMMAND).asBytes(plugin)
).dispatch(plugin, RedisMessageType.RETURN_USER_DATA) ).dispatch(plugin, RedisMessage.Type.RETURN_USER_DATA)
); );
case RETURN_USER_DATA -> { case RETURN_USER_DATA -> {
final CompletableFuture<Optional<DataSnapshot.Packed>> future = pendingRequests.get( final CompletableFuture<Optional<DataSnapshot.Packed>> future = pendingRequests.get(
redisMessage.getTargetUuid() redisMessage.getTargetUuid()
); );
if (future != null) { if (future != null) {
future.complete(Optional.of(DataSnapshot.deserialize(plugin, redisMessage.getPayload()))); try {
final DataSnapshot.Packed data = DataSnapshot.deserialize(plugin, redisMessage.getPayload());
future.complete(Optional.of(data));
} catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred returning user data from Redis", e);
future.complete(Optional.empty());
}
pendingRequests.remove(redisMessage.getTargetUuid()); pendingRequests.remove(redisMessage.getTargetUuid());
} }
} }
} }
} }
@Override
public void onSubscribe(String channel, int subscribedChannels) {
plugin.log(Level.INFO, "Redis subscribed to channel '" + channel + "'");
}
@Override
public void onUnsubscribe(String channel, int subscribedChannels) {
plugin.log(Level.INFO, "Redis unsubscribed from channel '" + channel + "'");
}
@Blocking @Blocking
protected void sendMessage(@NotNull String channel, @NotNull String message) { protected void sendMessage(@NotNull String channel, @NotNull String message) {
try (Jedis jedis = jedisPool.getResource()) { try (Jedis jedis = jedisPool.getResource()) {
@@ -142,7 +213,7 @@ public class RedisManager extends JedisPubSub {
public void sendUserDataUpdate(@NotNull User user, @NotNull DataSnapshot.Packed data) { public void sendUserDataUpdate(@NotNull User user, @NotNull DataSnapshot.Packed data) {
plugin.runAsync(() -> { plugin.runAsync(() -> {
final RedisMessage redisMessage = RedisMessage.create(user.getUuid(), data.asBytes(plugin)); final RedisMessage redisMessage = RedisMessage.create(user.getUuid(), data.asBytes(plugin));
redisMessage.dispatch(plugin, RedisMessageType.UPDATE_USER_DATA); redisMessage.dispatch(plugin, RedisMessage.Type.UPDATE_USER_DATA);
}); });
} }
@@ -162,10 +233,11 @@ public class RedisManager extends JedisPubSub {
user.getUuid(), user.getUuid(),
requestId.toString().getBytes(StandardCharsets.UTF_8) requestId.toString().getBytes(StandardCharsets.UTF_8)
); );
redisMessage.dispatch(plugin, RedisMessageType.REQUEST_USER_DATA); redisMessage.dispatch(plugin, RedisMessage.Type.REQUEST_USER_DATA);
}); });
return future.orTimeout( return future
plugin.getSettings().getNetworkLatencyMilliseconds(), .orTimeout(
plugin.getSettings().getSynchronization().getNetworkLatencyMilliseconds(),
TimeUnit.MILLISECONDS TimeUnit.MILLISECONDS
) )
.exceptionally(throwable -> { .exceptionally(throwable -> {
@@ -175,70 +247,137 @@ public class RedisManager extends JedisPubSub {
} }
/** /**
* Set a user's data to the Redis server * Set a user's data to Redis
* *
* @param user the user to set data for * @param user the user to set data for
* @param data the user's data to set * @param data the user's data to set
* @param timeToLive The time to cache the data for
*/ */
public void setUserData(@NotNull User user, @NotNull DataSnapshot.Packed data) { @Blocking
plugin.runAsync(() -> { public void setUserData(@NotNull User user, @NotNull DataSnapshot.Packed data, int timeToLive) {
try (Jedis jedis = jedisPool.getResource()) { try (Jedis jedis = jedisPool.getResource()) {
jedis.setex( jedis.setex(
getKey(RedisKeyType.DATA_UPDATE, user.getUuid(), clusterId), getKey(RedisKeyType.LATEST_SNAPSHOT, user.getUuid(), clusterId),
RedisKeyType.DATA_UPDATE.getTimeToLive(), timeToLive,
data.asBytes(plugin) data.asBytes(plugin)
);
plugin.debug(String.format("[%s] Set %s key on Redis", user.getUsername(), RedisKeyType.LATEST_SNAPSHOT));
} catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred setting user data on Redis", e);
}
}
@Blocking
public void clearUserData(@NotNull User user) {
try (Jedis jedis = jedisPool.getResource()) {
jedis.del(
getKey(RedisKeyType.LATEST_SNAPSHOT, user.getUuid(), clusterId)
);
plugin.debug(String.format("[%s] Cleared %s on Redis", user.getUsername(), RedisKeyType.LATEST_SNAPSHOT));
} catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred clearing user data on Redis", e);
}
}
@Blocking
public void setUserCheckedOut(@NotNull User user, boolean checkedOut) {
try (Jedis jedis = jedisPool.getResource()) {
final String key = getKeyString(RedisKeyType.DATA_CHECKOUT, user.getUuid(), clusterId);
if (checkedOut) {
jedis.set(
key.getBytes(StandardCharsets.UTF_8),
plugin.getServerName().getBytes(StandardCharsets.UTF_8)
); );
plugin.debug(String.format("[%s] Set %s key to redis at: %s", user.getUsername(), } else {
RedisKeyType.DATA_UPDATE.name(), new SimpleDateFormat("mm:ss.SSS").format(new Date()))); if (jedis.del(key.getBytes(StandardCharsets.UTF_8)) == 0) {
} catch (Throwable e) { plugin.debug(String.format("[%s] %s key not set on Redis when attempting removal (%s)",
plugin.log(Level.SEVERE, "An exception occurred setting a user's server switch", e); user.getUsername(), RedisKeyType.DATA_CHECKOUT, key));
return;
}
} }
}); plugin.debug(String.format("[%s] %s %s key %s Redis (%s)", user.getUsername(),
checkedOut ? "Set" : "Removed", RedisKeyType.DATA_CHECKOUT, checkedOut ? "to" : "from", key));
} catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred setting checkout to", e);
}
}
@Blocking
public Optional<String> getUserCheckedOut(@NotNull User user) {
try (Jedis jedis = jedisPool.getResource()) {
final byte[] key = getKey(RedisKeyType.DATA_CHECKOUT, user.getUuid(), clusterId);
final byte[] readData = jedis.get(key);
if (readData != null) {
final String checkoutServer = new String(readData, StandardCharsets.UTF_8);
plugin.debug(String.format("[%s] Waiting for %s %s key to be unset on Redis",
user.getUsername(), checkoutServer, RedisKeyType.DATA_CHECKOUT));
return Optional.of(checkoutServer);
}
} catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred getting a user's checkout key from Redis", e);
}
plugin.debug(String.format("[%s] %s key not set on Redis", user.getUsername(),
RedisKeyType.DATA_CHECKOUT));
return Optional.empty();
}
@Blocking
public void clearUsersCheckedOutOnServer() {
final String keyFormat = String.format("%s*", RedisKeyType.DATA_CHECKOUT.getKeyPrefix(clusterId));
try (Jedis jedis = jedisPool.getResource()) {
final Set<String> keys = jedis.keys(keyFormat);
if (keys == null) {
plugin.log(Level.WARNING, "Checkout key returned null from Redis during clearing");
return;
}
for (String key : keys) {
if (jedis.get(key).equals(plugin.getServerName())) {
jedis.del(key);
}
}
} catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred clearing this server's checkout keys on Redis", e);
}
} }
/** /**
* Set a user's server switch to the Redis server * Set a user's server switch to Redis
* *
* @param user the user to set the server switch for * @param user the user to set the server switch for
* @return a future returning void when complete
*/ */
public CompletableFuture<Void> setUserServerSwitch(@NotNull User user) { @Blocking
final CompletableFuture<Void> future = new CompletableFuture<>(); public void setUserServerSwitch(@NotNull User user) {
plugin.runAsync(() -> { try (Jedis jedis = jedisPool.getResource()) {
try (Jedis jedis = jedisPool.getResource()) { jedis.setex(
jedis.setex( getKey(RedisKeyType.SERVER_SWITCH, user.getUuid(), clusterId),
getKey(RedisKeyType.SERVER_SWITCH, user.getUuid(), clusterId), RedisKeyType.TTL_10_SECONDS,
RedisKeyType.SERVER_SWITCH.getTimeToLive(), new byte[0] new byte[0]
); );
future.complete(null); plugin.debug(String.format("[%s] Set %s key to Redis",
plugin.debug(String.format("[%s] Set %s key to redis at: %s", user.getUsername(), user.getUsername(), RedisKeyType.SERVER_SWITCH));
RedisKeyType.SERVER_SWITCH.name(), new SimpleDateFormat("mm:ss.SSS").format(new Date()))); } catch (Throwable e) {
} catch (Throwable e) { plugin.log(Level.SEVERE, "An exception occurred setting a user's server switch key from Redis", 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 * Fetch a user's data from Redis and consume the key if found
* *
* @param user The user to fetch data for * @param user The user to fetch data for
* @return The user's data, if it's present on the database. Otherwise, an empty optional. * @return The user's data, if it's present on the database. Otherwise, an empty optional.
*/ */
@Blocking
public Optional<DataSnapshot.Packed> getUserData(@NotNull User user) { public Optional<DataSnapshot.Packed> getUserData(@NotNull User user) {
try (Jedis jedis = jedisPool.getResource()) { try (Jedis jedis = jedisPool.getResource()) {
final byte[] key = getKey(RedisKeyType.DATA_UPDATE, user.getUuid(), clusterId); final byte[] key = getKey(RedisKeyType.LATEST_SNAPSHOT, user.getUuid(), clusterId);
final byte[] dataByteArray = jedis.get(key); final byte[] dataByteArray = jedis.get(key);
if (dataByteArray == null) { if (dataByteArray == null) {
plugin.debug("[" + user.getUsername() + "] Could not read " + plugin.debug(String.format("[%s] Waiting for %s key from Redis",
RedisKeyType.DATA_UPDATE.name() + " key from redis at: " + user.getUsername(), RedisKeyType.LATEST_SNAPSHOT));
new SimpleDateFormat("mm:ss.SSS").format(new Date()));
return Optional.empty(); return Optional.empty();
} }
plugin.debug("[" + user.getUsername() + "] Successfully read " plugin.debug(String.format("[%s] Read %s key from Redis",
+ RedisKeyType.DATA_UPDATE.name() + " key from redis at: " + user.getUsername(), RedisKeyType.LATEST_SNAPSHOT));
new SimpleDateFormat("mm:ss.SSS").format(new Date()));
// Consume the key (delete from redis) // Consume the key (delete from redis)
jedis.del(key); jedis.del(key);
@@ -246,35 +385,36 @@ public class RedisManager extends JedisPubSub {
// Use Snappy to decompress the json // Use Snappy to decompress the json
return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray)); return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray));
} catch (Throwable e) { } catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred fetching a user's data from redis", e); plugin.log(Level.SEVERE, "An exception occurred getting a user's data from Redis", e);
return Optional.empty(); return Optional.empty();
} }
} }
@Blocking
public boolean getUserServerSwitch(@NotNull User user) { public boolean getUserServerSwitch(@NotNull User user) {
try (Jedis jedis = jedisPool.getResource()) { try (Jedis jedis = jedisPool.getResource()) {
final byte[] key = getKey(RedisKeyType.SERVER_SWITCH, user.getUuid(), clusterId); final byte[] key = getKey(RedisKeyType.SERVER_SWITCH, user.getUuid(), clusterId);
final byte[] readData = jedis.get(key); final byte[] readData = jedis.get(key);
if (readData == null) { if (readData == null) {
plugin.debug("[" + user.getUsername() + "] Could not read " + plugin.debug(String.format("[%s] Waiting for %s key from Redis",
RedisKeyType.SERVER_SWITCH.name() + " key from redis at: " + user.getUsername(), RedisKeyType.SERVER_SWITCH));
new SimpleDateFormat("mm:ss.SSS").format(new Date()));
return false; return false;
} }
plugin.debug("[" + user.getUsername() + "] Successfully read " plugin.debug(String.format("[%s] Read %s key from Redis",
+ RedisKeyType.SERVER_SWITCH.name() + " key from redis at: " + user.getUsername(), RedisKeyType.SERVER_SWITCH));
new SimpleDateFormat("mm:ss.SSS").format(new Date()));
// Consume the key (delete from redis) // Consume the key (delete from redis)
jedis.del(key); jedis.del(key);
return true; return true;
} catch (Throwable e) { } catch (Throwable e) {
plugin.log(Level.SEVERE, "An exception occurred fetching a user's server switch from redis", e); plugin.log(Level.SEVERE, "An exception occurred getting a user's server switch from Redis", e);
return false; return false;
} }
} }
@Blocking
public void terminate() { public void terminate() {
enabled = false;
if (jedisPool != null) { if (jedisPool != null) {
if (!jedisPool.isClosed()) { if (!jedisPool.isClosed()) {
jedisPool.close(); jedisPool.close();
@@ -284,7 +424,12 @@ public class RedisManager extends JedisPubSub {
} }
private static byte[] getKey(@NotNull RedisKeyType keyType, @NotNull UUID uuid, @NotNull String clusterId) { 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); return getKeyString(keyType, uuid, clusterId).getBytes(StandardCharsets.UTF_8);
}
@NotNull
private static String getKeyString(@NotNull RedisKeyType keyType, @NotNull UUID uuid, @NotNull String clusterId) {
return String.format("%s:%s", keyType.getKeyPrefix(clusterId), uuid);
} }
} }

View File

@@ -21,16 +21,23 @@ package net.william278.husksync.redis;
import com.google.gson.JsonSyntaxException; import com.google.gson.JsonSyntaxException;
import com.google.gson.annotations.SerializedName; import com.google.gson.annotations.SerializedName;
import lombok.Getter;
import lombok.Setter;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.Adaptable; import net.william278.husksync.adapter.Adaptable;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public class RedisMessage implements Adaptable { public class RedisMessage implements Adaptable {
@SerializedName("target_uuid") @SerializedName("target_uuid")
private UUID targetUuid; private UUID targetUuid;
@Getter
@Setter
@SerializedName("payload") @SerializedName("payload")
private byte[] payload; private byte[] payload;
@@ -53,7 +60,7 @@ public class RedisMessage implements Adaptable {
return plugin.getGson().fromJson(json, RedisMessage.class); return plugin.getGson().fromJson(json, RedisMessage.class);
} }
public void dispatch(@NotNull HuskSync plugin, @NotNull RedisMessageType type) { public void dispatch(@NotNull HuskSync plugin, @NotNull Type type) {
plugin.runAsync(() -> plugin.getRedisManager().sendMessage( plugin.runAsync(() -> plugin.getRedisManager().sendMessage(
type.getMessageChannel(plugin.getSettings().getClusterId()), type.getMessageChannel(plugin.getSettings().getClusterId()),
plugin.getGson().toJson(this) plugin.getGson().toJson(this)
@@ -69,12 +76,27 @@ public class RedisMessage implements Adaptable {
this.targetUuid = targetUuid; this.targetUuid = targetUuid;
} }
public byte[] getPayload() { public enum Type {
return payload;
}
public void setPayload(byte[] payload) { UPDATE_USER_DATA,
this.payload = payload; REQUEST_USER_DATA,
} RETURN_USER_DATA;
@NotNull
public String getMessageChannel(@NotNull String clusterId) {
return String.format(
"%s:%s:%s",
RedisManager.KEY_NAMESPACE.toLowerCase(Locale.ENGLISH),
clusterId.toLowerCase(Locale.ENGLISH),
name().toLowerCase(Locale.ENGLISH)
);
}
public static Optional<Type> getTypeFromChannel(@NotNull String channel, @NotNull String clusterId) {
return Arrays.stream(values())
.filter(messageType -> messageType.getMessageChannel(clusterId).equalsIgnoreCase(channel))
.findFirst();
}
}
} }

View File

@@ -0,0 +1,239 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.sync;
import net.william278.husksync.HuskSync;
import net.william278.husksync.api.HuskSyncAPI;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.database.Database;
import net.william278.husksync.redis.RedisManager;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User;
import net.william278.husksync.util.Task;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.logging.Level;
/**
* Handles the synchronization of data when a player changes servers or logs in
*
* @since 3.1
*/
public abstract class DataSyncer {
private static final long BASE_LISTEN_ATTEMPTS = 16;
private static final long LISTEN_DELAY = 10;
protected final HuskSync plugin;
private final long maxListenAttempts;
@ApiStatus.Internal
protected DataSyncer(@NotNull HuskSync plugin) {
this.plugin = plugin;
this.maxListenAttempts = getMaxListenAttempts();
}
/**
* API-exposed constructor for a {@link DataSyncer}
*
* @param api instance of the {@link HuskSyncAPI}
*/
@SuppressWarnings("unused")
public DataSyncer(@NotNull HuskSyncAPI api) {
this(api.getPlugin());
}
/**
* Called when the plugin is enabled
*/
public void initialize() {
}
/**
* Called when the plugin is disabled
*/
public void terminate() {
}
/**
* Called when a user's data should be fetched and applied to them as part of a synchronization process
*
* @param user the user to fetch data for
*/
public abstract void syncApplyUserData(@NotNull OnlineUser user);
/**
* Called when a user's data should be serialized and saved as part of a synchronization process
*
* @param user the user to save
*/
public abstract void syncSaveUserData(@NotNull OnlineUser user);
/**
* Save a {@link DataSnapshot.Packed user's data snapshot} to the database,
* first firing the {@link net.william278.husksync.event.DataSaveEvent}. This will not update data on Redis.
*
* @param user the user to save the data for
* @param data the data to save
* @param after a consumer to run after data has been saved. Will be run async (off the main thread).
* @apiNote Data will not be saved if the {@link net.william278.husksync.event.DataSaveEvent} is canceled.
* Note that this method can also edit the data before saving it.
* @implNote Note that the {@link net.william278.husksync.event.DataSaveEvent} will <b>not</b> be fired if
* {@link DataSnapshot.SaveCause#fireDataSaveEvent()} is {@code false} (e.g., with the SERVER_SHUTDOWN cause).
* @since 3.3.2
*/
@Blocking
public void saveData(@NotNull User user, @NotNull DataSnapshot.Packed data,
@Nullable BiConsumer<User, DataSnapshot.Packed> after) {
if (!data.getSaveCause().fireDataSaveEvent()) {
addSnapshotToDatabase(user, data, after);
return;
}
plugin.fireEvent(
plugin.getDataSaveEvent(user, data),
(event) -> addSnapshotToDatabase(user, data, after)
);
}
/**
* Save a {@link DataSnapshot.Packed user's data snapshot} to the database,
* first firing the {@link net.william278.husksync.event.DataSaveEvent}. This will not update data on Redis.
*
* @param user the user to save the data for
* @param data the data to save
* @apiNote Data will not be saved if the {@link net.william278.husksync.event.DataSaveEvent} is canceled.
* Note that this method can also edit the data before saving it.
* @implNote Note that the {@link net.william278.husksync.event.DataSaveEvent} will <b>not</b> be fired if
* {@link DataSnapshot.SaveCause#fireDataSaveEvent()} is {@code false} (e.g., with the SERVER_SHUTDOWN cause).
* @since 3.3.3
*/
public void saveData(@NotNull User user, @NotNull DataSnapshot.Packed data) {
saveData(user, data, null);
}
// Adds a snapshot to the database and runs the after consumer
@Blocking
private void addSnapshotToDatabase(@NotNull User user, @NotNull DataSnapshot.Packed data,
@Nullable BiConsumer<User, DataSnapshot.Packed> after) {
getDatabase().addSnapshot(user, data);
if (after != null) {
after.accept(user, data);
}
}
// Calculates the max attempts the system should listen for user data for based on the latency value
private long getMaxListenAttempts() {
return BASE_LISTEN_ATTEMPTS + (
(Math.max(100, plugin.getSettings().getSynchronization().getNetworkLatencyMilliseconds()) / 1000)
* 20 / LISTEN_DELAY
);
}
// Set a user's data from the database, or set them as a new user
@ApiStatus.Internal
protected void setUserFromDatabase(@NotNull OnlineUser user) {
try {
getDatabase().getLatestSnapshot(user).ifPresentOrElse(
snapshot -> user.applySnapshot(snapshot, DataSnapshot.UpdateCause.SYNCHRONIZED),
() -> user.completeSync(true, DataSnapshot.UpdateCause.NEW_USER, plugin)
);
} catch (Throwable e) {
plugin.log(Level.WARNING, "Failed to set %s's data from the database".formatted(user.getUsername()), e);
user.completeSync(false, DataSnapshot.UpdateCause.SYNCHRONIZED, plugin);
}
}
// Continuously listen for data from Redis
@ApiStatus.Internal
protected void listenForRedisData(@NotNull OnlineUser user, @NotNull Supplier<Boolean> completionSupplier) {
final AtomicLong timesRun = new AtomicLong(0L);
final AtomicReference<Task.Repeating> task = new AtomicReference<>();
final AtomicBoolean processing = new AtomicBoolean(false);
final Runnable runnable = () -> {
if (user.isOffline()) {
task.get().cancel();
return;
}
// Ensure only one task is running at a time
if (processing.getAndSet(true)) {
return;
}
// Timeout if the plugin is disabling or the max attempts have been reached
if (plugin.isDisabling() || timesRun.getAndIncrement() > maxListenAttempts) {
task.get().cancel();
plugin.debug(String.format("[%s] Redis timed out after %s attempts; setting from database",
user.getUsername(), timesRun.get()));
setUserFromDatabase(user);
return;
}
// Fire the completion supplier
if (completionSupplier.get()) {
task.get().cancel();
}
processing.set(false);
};
task.set(plugin.getRepeatingTask(runnable, LISTEN_DELAY));
task.get().run();
}
@NotNull
protected RedisManager getRedis() {
return plugin.getRedisManager();
}
@NotNull
protected Database getDatabase() {
return plugin.getDatabase();
}
/**
* Represents the different available default modes of {@link DataSyncer}
*
* @since 3.1
*/
public enum Mode {
LOCKSTEP(LockstepDataSyncer::new),
DELAY(DelayDataSyncer::new);
private final Function<HuskSync, ? extends DataSyncer> supplier;
Mode(@NotNull Function<HuskSync, ? extends DataSyncer> supplier) {
this.supplier = supplier;
}
@NotNull
public DataSyncer create(@NotNull HuskSync plugin) {
return supplier.apply(plugin);
}
}
}

View File

@@ -0,0 +1,71 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.sync;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.redis.RedisKeyType;
import net.william278.husksync.user.OnlineUser;
import org.jetbrains.annotations.NotNull;
/**
* A data syncer which applies a network delay before checking the presence of user data
*/
public class DelayDataSyncer extends DataSyncer {
public DelayDataSyncer(@NotNull HuskSync plugin) {
super(plugin);
}
@Override
public void syncApplyUserData(@NotNull OnlineUser user) {
plugin.runAsyncDelayed(
() -> {
// Fetch from the database if the user isn't changing servers
if (!getRedis().getUserServerSwitch(user)) {
this.setUserFromDatabase(user);
return;
}
// Listen for the data to be updated
this.listenForRedisData(
user,
() -> getRedis().getUserData(user).map(data -> {
user.applySnapshot(data, DataSnapshot.UpdateCause.SYNCHRONIZED);
return true;
}).orElse(false)
);
},
Math.max(0, plugin.getSettings().getSynchronization().getNetworkLatencyMilliseconds() / 50L)
);
}
@Override
public void syncSaveUserData(@NotNull OnlineUser onlineUser) {
plugin.runAsync(() -> {
getRedis().setUserServerSwitch(onlineUser);
saveData(
onlineUser, onlineUser.createSnapshot(DataSnapshot.SaveCause.DISCONNECT),
(user, data) -> getRedis().setUserData(user, data, RedisKeyType.TTL_10_SECONDS)
);
});
}
}

View File

@@ -0,0 +1,71 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.sync;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.redis.RedisKeyType;
import net.william278.husksync.user.OnlineUser;
import org.jetbrains.annotations.NotNull;
public class LockstepDataSyncer extends DataSyncer {
public LockstepDataSyncer(@NotNull HuskSync plugin) {
super(plugin);
}
@Override
public void initialize() {
getRedis().clearUsersCheckedOutOnServer();
}
@Override
public void terminate() {
getRedis().clearUsersCheckedOutOnServer();
}
// Consume their data when they are checked in
@Override
public void syncApplyUserData(@NotNull OnlineUser user) {
this.listenForRedisData(user, () -> {
if (getRedis().getUserCheckedOut(user).isPresent()) {
return false;
}
getRedis().setUserCheckedOut(user, true);
getRedis().getUserData(user).ifPresentOrElse(
data -> user.applySnapshot(data, DataSnapshot.UpdateCause.SYNCHRONIZED),
() -> this.setUserFromDatabase(user)
);
return true;
});
}
@Override
public void syncSaveUserData(@NotNull OnlineUser onlineUser) {
plugin.runAsync(() -> saveData(
onlineUser, onlineUser.createSnapshot(DataSnapshot.SaveCause.DISCONNECT),
(user, data) -> {
getRedis().setUserData(user, data, RedisKeyType.TTL_1_YEAR);
getRedis().setUserCheckedOut(user, false);
}
));
}
}

View File

@@ -20,6 +20,7 @@
package net.william278.husksync.user; package net.william278.husksync.user;
import net.kyori.adventure.audience.Audience; import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.platform.AudienceProvider;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
public final class ConsoleUser implements CommandUser { public final class ConsoleUser implements CommandUser {
@@ -27,8 +28,8 @@ public final class ConsoleUser implements CommandUser {
@NotNull @NotNull
private final Audience audience; private final Audience audience;
public ConsoleUser(@NotNull Audience console) { public ConsoleUser(@NotNull AudienceProvider audiences) {
this.audience = console; this.audience = audiences.console();
} }
@Override @Override
@@ -41,4 +42,6 @@ public final class ConsoleUser implements CommandUser {
public boolean hasPermission(@NotNull String permission) { public boolean hasPermission(@NotNull String permission) {
return true; return true;
} }
} }

View File

@@ -20,7 +20,6 @@
package net.william278.husksync.user; package net.william278.husksync.user;
import de.themoep.minedown.adventure.MineDown; import de.themoep.minedown.adventure.MineDown;
import de.themoep.minedown.adventure.MineDownParser;
import net.kyori.adventure.audience.Audience; import net.kyori.adventure.audience.Audience;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
@@ -50,13 +49,11 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo
*/ */
public abstract boolean isOffline(); public abstract boolean isOffline();
/**
* Get the player's adventure {@link Audience}
*
* @return the player's {@link Audience}
*/
@NotNull @NotNull
public abstract Audience getAudience(); @Override
public Audience getAudience() {
return getPlugin().getAudience(getUuid());
}
/** /**
* Send a message to this player * Send a message to this player
@@ -73,9 +70,7 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo
* @param mineDown the parsed {@link MineDown} to send * @param mineDown the parsed {@link MineDown} to send
*/ */
public void sendMessage(@NotNull MineDown mineDown) { public void sendMessage(@NotNull MineDown mineDown) {
sendMessage(mineDown sendMessage(mineDown.toComponent());
.disable(MineDownParser.Option.SIMPLE_FORMATTING)
.replace().toComponent());
} }
/** /**
@@ -84,9 +79,7 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo
* @param mineDown the parsed {@link MineDown} to send * @param mineDown the parsed {@link MineDown} to send
*/ */
public void sendActionBar(@NotNull MineDown mineDown) { public void sendActionBar(@NotNull MineDown mineDown) {
getAudience().sendActionBar(mineDown getAudience().sendActionBar(mineDown.toComponent());
.disable(MineDownParser.Option.SIMPLE_FORMATTING)
.replace().toComponent());
} }
/** /**
@@ -96,7 +89,9 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo
* @param description the description 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 iconMaterial the namespace-keyed material to use as an hasIcon of the toast
* @param backgroundType the background ("ToastType") of the toast * @param backgroundType the background ("ToastType") of the toast
* @deprecated No longer supported
*/ */
@Deprecated(since = "3.6.7")
public abstract void sendToast(@NotNull MineDown title, @NotNull MineDown description, public abstract void sendToast(@NotNull MineDown title, @NotNull MineDown description,
@NotNull String iconMaterial, @NotNull String backgroundType); @NotNull String iconMaterial, @NotNull String backgroundType);
@@ -131,6 +126,9 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo
public void applySnapshot(@NotNull DataSnapshot.Packed snapshot, @NotNull DataSnapshot.UpdateCause cause) { public void applySnapshot(@NotNull DataSnapshot.Packed snapshot, @NotNull DataSnapshot.UpdateCause cause) {
getPlugin().fireEvent(getPlugin().getPreSyncEvent(this, snapshot), (event) -> { getPlugin().fireEvent(getPlugin().getPreSyncEvent(this, snapshot), (event) -> {
if (!isOffline()) { if (!isOffline()) {
getPlugin().debug(String.format("Applying snapshot (%s) to %s (cause: %s)",
snapshot.getShortId(), getUsername(), cause.getDisplayName()
));
UserDataHolder.super.applySnapshot( UserDataHolder.super.applySnapshot(
event.getData(), (succeeded) -> completeSync(succeeded, cause, getPlugin()) event.getData(), (succeeded) -> completeSync(succeeded, cause, getPlugin())
); );
@@ -146,19 +144,13 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo
*/ */
public void completeSync(boolean succeeded, @NotNull DataSnapshot.UpdateCause cause, @NotNull HuskSync plugin) { public void completeSync(boolean succeeded, @NotNull DataSnapshot.UpdateCause cause, @NotNull HuskSync plugin) {
if (succeeded) { if (succeeded) {
switch (plugin.getSettings().getNotificationDisplaySlot()) { switch (plugin.getSettings().getSynchronization().getNotificationDisplaySlot()) {
case CHAT -> cause.getCompletedLocale(plugin).ifPresent(this::sendMessage); case CHAT -> cause.getCompletedLocale(plugin).ifPresent(this::sendMessage);
case ACTION_BAR -> cause.getCompletedLocale(plugin).ifPresent(this::sendActionBar); 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.fireEvent(
plugin.getSyncCompleteEvent(this), plugin.getSyncCompleteEvent(this),
(event) -> plugin.getLockedPlayers().remove(getUuid()) (event) -> plugin.unlockPlayer(getUuid())
); );
} else { } else {
cause.getFailedLocale(plugin).ifPresent(this::sendMessage); cause.getFailedLocale(plugin).ifPresent(this::sendMessage);

View File

@@ -0,0 +1,76 @@
/*
* 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 de.exlll.configlib.Configuration;
import de.exlll.configlib.YamlConfigurationProperties;
import de.exlll.configlib.YamlConfigurationStore;
import net.william278.desertwell.util.Version;
import net.william278.husksync.HuskSync;
import org.jetbrains.annotations.NotNull;
import java.io.InputStream;
import java.util.Objects;
import java.util.logging.Level;
import static net.william278.husksync.config.ConfigProvider.YAML_CONFIGURATION_PROPERTIES;
public interface CompatibilityChecker {
String COMPATIBILITY_FILE = "compatibility.yml";
default void checkCompatibility() throws HuskSync.FailedToLoadException {
final YamlConfigurationProperties p = YAML_CONFIGURATION_PROPERTIES.build();
final Version compatible;
// Load compatibility file
try (InputStream input = getResource(COMPATIBILITY_FILE)) {
final CompatibilityConfig compat = new YamlConfigurationStore<>(CompatibilityConfig.class, p).read(input);
compatible = Objects.requireNonNull(compat.getCompatibleWith());
} catch (Throwable e) {
getPlugin().log(Level.WARNING, "Failed to load compatibility config, skipping check.", e);
return;
}
// Check compatibility
if (compatible.compareTo(getPlugin().getMinecraftVersion()) != 0) {
throw new HuskSync.FailedToLoadException("""
Incompatible Minecraft version. This version of HuskSync is designed for Minecraft %s.
Please download the correct version of HuskSync for your server's Minecraft version (%s)."""
.formatted(compatible.toString(), getPlugin().getMinecraftVersion().toString()));
}
}
InputStream getResource(@NotNull String name);
@NotNull
HuskSync getPlugin();
@Configuration
record CompatibilityConfig(@NotNull String minecraftVersion) {
@NotNull
public Version getCompatibleWith() {
return Version.fromString(minecraftVersion);
}
}
}

View File

@@ -28,9 +28,12 @@ import org.jetbrains.annotations.NotNull;
import java.io.*; import java.io.*;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL; import java.net.URL;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.Locale; import java.util.Locale;
import java.util.StringJoiner; import java.util.StringJoiner;
@@ -80,7 +83,7 @@ public class DataDumper {
@NotNull @NotNull
public String toWeb() { public String toWeb() {
try { try {
final URL url = new URL(LOGS_SITE_ENDPOINT); final URL url = URI.create(LOGS_SITE_ENDPOINT).toURL();
final HttpURLConnection connection = (HttpURLConnection) url.openConnection(); final HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST"); connection.setRequestMethod("POST");
connection.setDoOutput(true); connection.setDoOutput(true);
@@ -115,7 +118,7 @@ public class DataDumper {
} else { } else {
return "(Failed to upload to logs site, got: " + connection.getResponseCode() + ")"; return "(Failed to upload to logs site, got: " + connection.getResponseCode() + ")";
} }
} catch (Exception e) { } catch (Throwable e) {
plugin.log(Level.SEVERE, "Failed to upload data to logs site", e); plugin.log(Level.SEVERE, "Failed to upload data to logs site", e);
} }
return "(Failed to upload to logs site)"; return "(Failed to upload to logs site)";
@@ -133,16 +136,13 @@ public class DataDumper {
*/ */
@NotNull @NotNull
public String toFile() throws IOException { public String toFile() throws IOException {
final File filePath = getFilePath(); final Path filePath = getFilePath();
try (final FileWriter writer = new FileWriter(filePath.toFile(), StandardCharsets.UTF_8, false)) {
// Write the data from #getString to the file using a writer writer.write(toString()); // Write the data from #getString to the file using a writer
try (final FileWriter writer = new FileWriter(filePath, StandardCharsets.UTF_8, false)) { return filePath.toString();
writer.write(toString());
} catch (IOException e) { } catch (IOException e) {
throw new IOException("Failed to write data to file", e); throw new IOException("Failed to write data to file", e);
} }
return "~/plugins/HuskSync/dumps/" + filePath.getName();
} }
/** /**
@@ -152,8 +152,8 @@ public class DataDumper {
* @throws IOException if the prerequisite dumps parent folder could not be created * @throws IOException if the prerequisite dumps parent folder could not be created
*/ */
@NotNull @NotNull
private File getFilePath() throws IOException { private Path getFilePath() throws IOException {
return new File(getDumpsFolder(), getFileName()); return getDumpsFolder().resolve(getFileName());
} }
/** /**
@@ -163,14 +163,12 @@ public class DataDumper {
* @throws IOException if the folder could not be created * @throws IOException if the folder could not be created
*/ */
@NotNull @NotNull
private File getDumpsFolder() throws IOException { private Path getDumpsFolder() throws IOException {
final File dumpsFolder = new File(plugin.getDataFolder(), "dumps"); final Path dumps = plugin.getConfigDirectory().resolve("dumps");
if (!dumpsFolder.exists()) { if (!Files.exists(dumps)) {
if (!dumpsFolder.mkdirs()) { Files.createDirectory(dumps);
throw new IOException("Failed to create user data dumps folder");
}
} }
return dumpsFolder; return dumps;
} }
/** /**
@@ -181,11 +179,11 @@ public class DataDumper {
@NotNull @NotNull
private String getFileName() { private String getFileName() {
return new StringJoiner("_") return new StringJoiner("_")
.add(user.getUsername()) .add(user.getUsername())
.add(snapshot.getTimestamp().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"))) .add(snapshot.getTimestamp().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")))
.add(snapshot.getSaveCause().name().toLowerCase(Locale.ENGLISH)) .add(snapshot.getSaveCause().name().toLowerCase(Locale.ENGLISH))
.add(snapshot.getShortId()) .add(snapshot.getShortId())
+ ".json"; + ".json";
} }
} }

View File

@@ -27,6 +27,7 @@ import net.william278.paginedown.PaginatedList;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
@@ -46,18 +47,19 @@ public class DataSnapshotList {
final AtomicInteger snapshotNumber = new AtomicInteger(1); final AtomicInteger snapshotNumber = new AtomicInteger(1);
this.paginatedList = PaginatedList.of(snapshots.stream() this.paginatedList = PaginatedList.of(snapshots.stream()
.map(snapshot -> plugin.getLocales() .map(snapshot -> plugin.getLocales()
.getRawLocale("data_list_item", .getRawLocale(!snapshot.isInvalid() ? "data_list_item" : "data_list_item_invalid",
getNumberIcon(snapshotNumber.getAndIncrement()), getNumberIcon(snapshotNumber.getAndIncrement()),
dataOwner.getUsername(), dataOwner.getUsername(),
snapshot.getId().toString(), snapshot.getId().toString(),
snapshot.getShortId(), snapshot.getShortId(),
snapshot.isPinned() ? "" : " ", snapshot.isPinned() ? "" : " ",
snapshot.getTimestamp().format(DateTimeFormatter snapshot.getTimestamp().format(DateTimeFormatter
.ofPattern("dd/MM/yyyy, HH:mm")), .ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)),
snapshot.getTimestamp().format(DateTimeFormatter snapshot.getTimestamp().format(DateTimeFormatter
.ofPattern("MMM dd yyyy, HH:mm:ss.SSS")), .ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.MEDIUM)),
snapshot.getSaveCause().getDisplayName(), snapshot.getSaveCause().getLocale(plugin),
String.format("%.2fKiB", snapshot.getFileSize(plugin) / 1024f)) String.format("%.2fKiB", snapshot.getFileSize(plugin) / 1024f),
snapshot.isInvalid() ? snapshot.getInvalidReason(plugin) : "")
.orElse("" + snapshot.getId())).toList(), .orElse("" + snapshot.getId())).toList(),
plugin.getLocales().getBaseChatList(6) plugin.getLocales().getBaseChatList(6)
.setHeaderFormat(plugin.getLocales() .setHeaderFormat(plugin.getLocales()

View File

@@ -28,6 +28,7 @@ import net.william278.husksync.user.User;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Optional; import java.util.Optional;
@@ -61,28 +62,32 @@ public class DataSnapshotOverview {
dataOwner.getUsername(), dataOwner.getUuid().toString()) dataOwner.getUsername(), dataOwner.getUuid().toString())
.ifPresent(user::sendMessage); .ifPresent(user::sendMessage);
locales.getLocale("data_manager_timestamp", locales.getLocale("data_manager_timestamp",
snapshot.getTimestamp().format(DateTimeFormatter.ofPattern("MMM dd yyyy, HH:mm:ss.SSS")), snapshot.getTimestamp().format(DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)),
snapshot.getTimestamp().toString()) snapshot.getTimestamp().toString())
.ifPresent(user::sendMessage); .ifPresent(user::sendMessage);
if (snapshot.isPinned()) { if (snapshot.isPinned()) {
locales.getLocale("data_manager_pinned") locales.getLocale("data_manager_pinned")
.ifPresent(user::sendMessage); .ifPresent(user::sendMessage);
} }
locales.getLocale("data_manager_cause", snapshot.getSaveCause().getDisplayName()) locales.getLocale("data_manager_cause", snapshot.getSaveCause().getLocale(plugin))
.ifPresent(user::sendMessage);
locales.getLocale("data_manager_server", snapshot.getServerName())
.ifPresent(user::sendMessage); .ifPresent(user::sendMessage);
// User status data, if present in the snapshot // User status data, if present in the snapshot
final Optional<Data.Health> health = snapshot.getHealth(); final Optional<Data.Health> health = snapshot.getHealth();
final Optional<Data.Attributes> attributes = snapshot.getAttributes();
final Optional<Data.Hunger> food = snapshot.getHunger(); final Optional<Data.Hunger> food = snapshot.getHunger();
final Optional<Data.Experience> experience = snapshot.getExperience(); final Optional<Data.Experience> exp = snapshot.getExperience();
final Optional<Data.GameMode> gameMode = snapshot.getGameMode(); final Optional<Data.GameMode> mode = snapshot.getGameMode();
if (health.isPresent() && food.isPresent() && experience.isPresent() && gameMode.isPresent()) { if (health.isPresent() && attributes.isPresent() && food.isPresent() && exp.isPresent() && mode.isPresent()) {
locales.getLocale("data_manager_status", locales.getLocale("data_manager_status",
Integer.toString((int) health.get().getHealth()), Integer.toString((int) health.get().getHealth()),
Integer.toString((int) health.get().getMaxHealth()), Integer.toString((int) attributes.get().getMaxHealth()),
Integer.toString(food.get().getFoodLevel()), Integer.toString(food.get().getFoodLevel()),
Integer.toString(experience.get().getExpLevel()), Integer.toString(exp.get().getExpLevel()),
gameMode.get().getGameMode().toLowerCase(Locale.ENGLISH)) mode.get().getGameMode().toLowerCase(Locale.ENGLISH))
.ifPresent(user::sendMessage); .ifPresent(user::sendMessage);
} }
@@ -101,7 +106,7 @@ public class DataSnapshotOverview {
.ifPresent(user::sendMessage); .ifPresent(user::sendMessage);
if (user.hasPermission("husksync.command.inventory.edit") if (user.hasPermission("husksync.command.inventory.edit")
&& user.hasPermission("husksync.command.enderchest.edit")) { && user.hasPermission("husksync.command.enderchest.edit")) {
locales.getLocale("data_manager_item_buttons", dataOwner.getUsername(), snapshot.getId().toString()) locales.getLocale("data_manager_item_buttons", dataOwner.getUsername(), snapshot.getId().toString())
.ifPresent(user::sendMessage); .ifPresent(user::sendMessage);
} }

View File

@@ -36,7 +36,7 @@ public abstract class LegacyConverter {
} }
@NotNull @NotNull
public abstract DataSnapshot.Packed convert(@NotNull byte[] data, @NotNull UUID id, public abstract DataSnapshot.Packed convert(byte @NotNull [] data, @NotNull UUID id,
@NotNull OffsetDateTime timestamp) throws DataAdapter.AdaptionException; @NotNull OffsetDateTime timestamp) throws DataAdapter.AdaptionException;
} }

View File

@@ -20,7 +20,9 @@
package net.william278.husksync.util; package net.william278.husksync.util;
import net.william278.husksync.HuskSync; import net.william278.husksync.HuskSync;
import net.william278.husksync.data.UserDataHolder;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@@ -86,7 +88,7 @@ public interface Task extends Runnable {
interface Supplier { interface Supplier {
@NotNull @NotNull
Task.Sync getSyncTask(@NotNull Runnable runnable, long delayTicks); Task.Sync getSyncTask(@NotNull Runnable runnable, @Nullable UserDataHolder user, long delayTicks);
@NotNull @NotNull
Task.Async getAsyncTask(@NotNull Runnable runnable, long delayTicks); Task.Async getAsyncTask(@NotNull Runnable runnable, long delayTicks);
@@ -95,8 +97,8 @@ public interface Task extends Runnable {
Task.Repeating getRepeatingTask(@NotNull Runnable runnable, long repeatingTicks); Task.Repeating getRepeatingTask(@NotNull Runnable runnable, long repeatingTicks);
@NotNull @NotNull
default Task.Sync runSyncDelayed(@NotNull Runnable runnable, long delayTicks) { default Task.Sync runSyncDelayed(@NotNull Runnable runnable, @Nullable UserDataHolder user, long delayTicks) {
final Task.Sync task = getSyncTask(runnable, delayTicks); final Task.Sync task = getSyncTask(runnable, user, delayTicks);
task.run(); task.run();
return task; return task;
} }
@@ -109,7 +111,12 @@ public interface Task extends Runnable {
@NotNull @NotNull
default Task.Sync runSync(@NotNull Runnable runnable) { default Task.Sync runSync(@NotNull Runnable runnable) {
return runSyncDelayed(runnable, 0); return runSyncDelayed(runnable, null, 0);
}
@NotNull
default Task.Sync runSync(@NotNull Runnable runnable, @NotNull UserDataHolder user) {
return runSyncDelayed(runnable, user, 0);
} }
@NotNull @NotNull

View File

@@ -0,0 +1,2 @@
# File used for checking Minecraft server compatibility with this version of HuskSync
minecraft_version: '${minecraft_version}'

View File

@@ -0,0 +1,22 @@
-- Create the users table if it does not exist
CREATE TABLE IF NOT EXISTS "%users_table%"
(
uuid uuid NOT NULL UNIQUE,
username varchar(16) NOT NULL,
PRIMARY KEY (uuid)
);
-- Create the user data table if it does not exist
CREATE TABLE IF NOT EXISTS "%user_data_table%"
(
version_uuid uuid NOT NULL UNIQUE,
player_uuid uuid NOT NULL,
timestamp timestamp NOT NULL,
save_cause varchar(32) NOT NULL,
pinned boolean NOT NULL DEFAULT FALSE,
data bytea NOT NULL,
PRIMARY KEY (version_uuid, player_uuid),
FOREIGN KEY (player_uuid) REFERENCES "%users_table%" (uuid) ON DELETE CASCADE
);

View File

@@ -1,49 +1,65 @@
synchronization_complete: '[⏵ Данните синхронизирани!](#00fb9a)' locales:
synchronization_failed: '[⏵ Провалихме се да синхронизираме Вашите данни! Моля свържете се с администратор.](#ff7e5e)' synchronization_complete: '[⏵ Данните синхронизирани!](#00fb9a)'
inventory_viewer_menu_title: '&0Инвентара на %1%' synchronization_failed: '[⏵ Провалихме се да синхронизираме Вашите данни! Моля свържете се с администратор.](#ff7e5e)'
ender_chest_viewer_menu_title: '&0Ендър Сандъка на %1%' inventory_viewer_menu_title: '&0Инвентара на %1%'
inventory_viewer_opened: '[Преглеждане снапшота на](#00fb9a) [%1%](#00fb9a bold) [ инвентар от ⌚ %2%](#00fb9a)' ender_chest_viewer_menu_title: '&0Ендър Сандъка на %1%'
ender_chest_viewer_opened: '[Преглеждане снапшота на](#00fb9a) [%1%](#00fb9a bold) [ Ендър Сандък от ⌚ %2%](#00fb9a)' inventory_viewer_opened: '[Преглеждане снапшота на](#00fb9a) [%1%](#00fb9a bold) [ инвентар от ⌚ %2%](#00fb9a)'
data_update_complete: '[🔔 Вашите данни бяха обновени!](#00fb9a)' ender_chest_viewer_opened: '[Преглеждане снапшота на](#00fb9a) [%1%](#00fb9a bold) [ Ендър Сандък от ⌚ %2%](#00fb9a)'
data_update_failed: '[🔔 Провалихме се да обновим Вашите данни! Моля свържете се с администратор.](#ff7e5e)' data_update_complete: '[🔔 Вашите данни бяха обновени!](#00fb9a)'
user_registration_complete: '[⭐ User registration complete!](#00fb9a)' data_update_failed: '[🔔 Провалихме се да обновим Вашите данни! Моля свържете се с администратор.](#ff7e5e)'
data_manager_title: '[Преглеждане потребителският снапшот](#00fb9a) [%1%](#00fb9a show_text=&7Версия на UUID:\n&8%2%) [за](#00fb9a) [%3%](#00fb9a bold show_text=&7UUID на Играча:\n&8%4%)[:](#00fb9a)' user_registration_complete: '[⭐ User registration complete!](#00fb9a)'
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Клеймо на Версията:\n&8Когато данните са били запазени)' data_manager_title: '[Преглеждане потребителският снапшот](#00fb9a) [%1%](#00fb9a show_text=&7Версия на UUID:\n&8%2%) [за](#00fb9a) [%3%](#00fb9a bold show_text=&7UUID на Играча:\n&8%4%)[:](#00fb9a)'
data_manager_pinned: '[※ Закачен снапшот](#d8ff2b show_text=&7Закачен:\n&8Снапшота на този потребител няма да бъде автоматично завъртан.)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Клеймо на Версията:\n&8Когато данните са били запазени)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Причина на Запазване:\n&8Какво е накарало данните да бъдат запазени)' data_manager_pinned: '[※ Закачен снапшот](#d8ff2b show_text=&7Закачен:\n&8Снапшота на този потребител няма да бъде автоматично завъртан.)'
data_manager_size: '[ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n' data_manager_cause: '[ %1%](#23a825-#36f539 show_text=&7Причина на Запазване:\n&8Какво е накарало данните да бъдат запазени)'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Точки кръв) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Точки глад) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7Ниво опит) [🏹 %5%](dark_aqua show_text=&7Режим на игра)' data_manager_server: '[%1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_advancements_statistics: '[⭐ Напредъци: %1%](color=#ffc43b-#f5c962 show_text=&7Напредъци, в които имате прогрес:\n&8%2%) [⌛ Изиграно Време: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Изиграно време в играта\n&8⚠ Базирано на статистики от играта)\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n'
data_manager_item_buttons: '[View:](gray) [[🪣 Инвентар…]](color=#a17b5f-#f5b98c show_text=&7Натиснете, за да прегледате run_command=/inventory %1% %2%) [[⌀ Ендър Сандък…]](#b649c4-#d254ff show_text=&7Натиснете, за да проверите run_command=/enderchest %1% %2%)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Точки кръв) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Точки глад) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7Ниво опит) [🏹 %5%](dark_aqua show_text=&7Режим на игра)'
data_manager_management_buttons: '[Управление:](gray) [[❌ Изтрий…]](#ff3300 show_text=&7Натиснете, за да изтриете този снапшот от потребителски данни.\n&8Това няма да засегне текущите данни на потребителя.\n&#ff3300&⚠ Това не може да бъде отменено! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ Възстанови…]](#00fb9a show_text=&7Натиснете, за да възстановите тези потребителски данни.\n&8Това ще зададе данните на потребителя на този снапшот.\n&#ff3300&⚠ Текущите данни на %1% ще бъдат пренаписани! suggest_command=/husksync:userdata restore %1% %2%) [[※ Закачи/Откачи…]](#d8ff2b show_text=&7Натиснете, за да закачите или откачите този снапшот с потребителски данни\n&8Закачените снапшоти няма да бъдат автоматично завъртани run_command=/userdata pin %1% %2%)' data_manager_advancements_statistics: '[⭐ Напредъци: %1%](color=#ffc43b-#f5c962 show_text=&7Напредъци, в които имате прогрес:\n&8%2%) [⌛ Изиграно Време: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Изиграно време в играта\n&8⚠ Базирано на статистики от играта)\n'
data_manager_system_buttons: '[System:](gray) [[⏷ File Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to a file.\n&8Data dumps can be found in ~/plugins/HuskSync/dumps/ run_command=/husksync:userdata dump %1% %2% file) [[☂ Web Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to the mc-logs service\n&8You will be provided with a URL containing the data. run_command=/husksync:userdata dump %1% %2% web)' data_manager_item_buttons: '[View:](gray) [[🪣 Инвентар…]](color=#a17b5f-#f5b98c show_text=&7Натиснете, за да прегледате run_command=/inventory %1% %2%) [[⌀ Ендър Сандък…]](#b649c4-#d254ff show_text=&7Натиснете, за да проверите run_command=/enderchest %1% %2%)'
data_manager_advancements_preview_remaining: 'и още %1%…' data_manager_management_buttons: '[Управление:](gray) [[❌ Изтрий…]](#ff3300 show_text=&7Натиснете, за да изтриете този снапшот от потребителски данни.\n&8Това няма да засегне текущите данни на потребителя.\n&#ff3300&⚠ Това не може да бъде отменено! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ Възстанови…]](#00fb9a show_text=&7Натиснете, за да възстановите тези потребителски данни.\n&8Това ще зададе данните на потребителя на този снапшот.\n&#ff3300&⚠ Текущите данни на %1% ще бъдат пренаписани! suggest_command=/husksync:userdata restore %1% %2%) [[※ Закачи/Откачи…]](#d8ff2b show_text=&7Натиснете, за да закачите или откачите този снапшот с потребителски данни\n&8Закачените снапшоти няма да бъдат автоматично завъртани run_command=/userdata pin %1% %2%)'
data_list_title: '[Лист от](#00fb9a) [снапшоти на данните на потребителя](#00fb9a) [%1%](#00fb9a bold show_text=&7UUID: %2%)\n' data_manager_system_buttons: '[System:](gray) [[⏷ File Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to a file.\n&8Data dumps can be found in ~/plugins/HuskSync/dumps/ run_command=/husksync:userdata dump %1% %2% file) [[☂ Web Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to the mc-logs service\n&8You will be provided with a URL containing the data. run_command=/husksync:userdata dump %1% %2% web)'
data_list_item: '[%1%](gray show_text=&7User Data Snapshot for %2%&8⚡ %4% run_command=/userdata view %2% %3%) [%5%](#d8ff2b show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962 show_text=&7Version timestamp:&7\n&8When the data was saved\n&8%7% run_command=/userdata view %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:&7\n&8Estimated file size of the snapshot (in KiB) run_command=/userdata view %2% %3%)' data_manager_advancements_preview_remaining: 'и още %1%…'
data_deleted: '[❌ Успешно изтрихме снапшота с потребителски данни](#00fb9a) [%1%](#00fb9a show_text=&7Версия на UUID:\n&8%2%) [за](#00fb9a) [%3%.](#00fb9a show_text=&7UUID на Играча:\n&8%4%)' data_list_title: '[Лист от](#00fb9a) [снапшоти на данните на потребителя](#00fb9a) [%1%](#00fb9a bold show_text=&7UUID: %2%)\n'
data_restored: '[⏪ Успешно възстановихме](#00fb9a) [текущите потребителски данни за](#00fb9a) [%1%](#00fb9a show_text=&7UUID на Играча:\n&8%2%) [от снапшот](#00fb9a) [%3%.](#00fb9a show_text=&7Версия на UUID:\n&8%4%)' data_list_item: '[%1%](gray show_text=&7User Data Snapshot for %2%\n&8⚡ %4% run_command=/userdata view %2% %3%) [%5%](#d8ff2b show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962 show_text=&7Version timestamp:&7\n&8When the data was saved\n&8%7% run_command=/userdata view %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:&7\n&8Estimated file size of the snapshot (in KiB) run_command=/userdata view %2% %3%)'
data_pinned: '[※ Успешно закачихме снапшота с потребителски данни](#00fb9a) [%1%](#00fb9a show_text=&7Версия на UUID:\n&8%2%) [за](#00fb9a) [%3%.](#00fb9a show_text=&7UUID на Играча:\n&8%4%)' data_list_item_invalid: '[%1%](dark_gray show_text=&7User Data Snapshot for %2%\n&8⚡ %4% suggest_command=/userdata delete %2% %3%) [%5%](dark_gray show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. suggest_command=/userdata delete %2% %3%) [%6% ⚑ %8% ⏏ %9%](gray strikethrough show_text=&#ff3300&Invalid Data Snapshot\n&#ff7e5e&Click to delete\n\n&7⚠ %10% suggest_command=/userdata delete %2% %3%)'
data_unpinned: '[ Успешно откачихме снапшота с потребителски данни](#00fb9a) [%1%](#00fb9a show_text=&7Версия на UUID:\n&8%2%) [за](#00fb9a) [%3%.](#00fb9a show_text=&7UUID на Играча:\n&8%4%)' data_deleted: '[ Успешно изтрихме снапшота с потребителски данни](#00fb9a) [%1%](#00fb9a show_text=&7Версия на UUID:\n&8%2%) [за](#00fb9a) [%3%.](#00fb9a show_text=&7UUID на Играча:\n&8%4%)'
data_dumped: '[☂ Successfully dumped the user data snapshot %1% for %2% to:](#00fb9a) &7%3%' data_restored: '[⏪ Успешно възстановихме](#00fb9a) [текущите потребителски данни за](#00fb9a) [%1%](#00fb9a show_text=&7UUID на Играча:\n&8%2%) [от снапшот](#00fb9a) [%3%.](#00fb9a show_text=&7Версия на UUID:\n&8%4%)'
list_footer: '\n%1%[Page](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%' data_pinned: '[※ Успешно закачихме снапшота с потребителски данни](#00fb9a) [%1%](#00fb9a show_text=&7Версия на UUID:\n&8%2%) [за](#00fb9a) [%3%.](#00fb9a show_text=&7UUID на Играча:\n&8%4%)'
list_previous_page_button: '[◀](white show_text=&7View previous page run_command=%2% %1%) ' data_unpinned: '[※ Успешно откачихме снапшота с потребителски данни](#00fb9a) [%1%](#00fb9a show_text=&7Версия на UUID:\n&8%2%) [за](#00fb9a) [%3%.](#00fb9a show_text=&7UUID на Играча:\n&8%4%)'
list_next_page_button: ' [▶](white show_text=&7View next page run_command=%2% %1%)' data_dumped: '[☂ Successfully dumped the user data snapshot %1% for %2% to:](#00fb9a) &7%3%'
list_page_jumpers: '(%1%)' list_footer: '\n%1%[Page](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%'
list_page_jumper_button: '[%1%](show_text=&7Jump to page %1% run_command=%2% %1%)' list_previous_page_button: '[◀](white show_text=&7View previous page run_command=%2% %1%) '
list_page_jumper_current_page: '[%1%](#00fb9a)' list_next_page_button: ' [▶](white show_text=&7View next page run_command=%2% %1%)'
list_page_jumper_separator: ' ' list_page_jumpers: '(%1%)'
list_page_jumper_group_separator: '' list_page_jumper_button: '[%1%](show_text=&7Jump to page %1% run_command=%2% %1%)'
up_to_date: '[HuskSync](#00fb9a bold) [| You are running the latest version of HuskSync (v%1%).](#00fb9a)' list_page_jumper_current_page: '[%1%](#00fb9a)'
update_available: '[HuskSync](#ff7e5e bold) [| A new version of HuskSync is available: v%1% (running: v%2%).](#ff7e5e)' list_page_jumper_separator: ' '
reload_complete: '[HuskSync](#00fb9a bold) [| Презаредихме конфигурацията и файловете със съобщения.](#00fb9a)\n[⚠ Ensure config files are up-to-date on all servers!](#00fb9a)\n[A restart is needed for config changes to take effect.](#00fb9a italic)' list_page_jumper_group_separator: '…'
error_invalid_syntax: '[Грешка:](#ff3300) [Неправилен синтаксис. Използвайте:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&Click to suggest suggest_command=%1%)' save_cause_disconnect: 'disconnect'
error_invalid_player: '[Грешка:](#ff3300) [Не можахме да открием играч с това име.](#ff7e5e)' save_cause_world_save: 'world save'
error_no_permission: '[Грешка:](#ff3300) [Нямате право да използвате тази команда](#ff7e5e)' save_cause_death: 'death'
error_console_command_only: '[Грешка:](#ff3300) [Тази команда може да бъде използвана единствено през конзолата](#ff7e5e)' save_cause_server_shutdown: 'server shutdown'
error_in_game_command_only: 'Грешка: Тази команда може да бъде използвана само от играта.' save_cause_inventory_command: 'inventory command'
error_no_data_to_display: '[Грешка:](#ff3300) [Не можахме да открием никакви данни за потребителя, които да покажем.](#ff7e5e)' save_cause_enderchest_command: 'enderchest command'
error_invalid_version_uuid: '[Грешка:](#ff3300) [Не можахме да открием никакви потребителски данни за тази версия на това UUID.](#ff7e5e)' save_cause_backup_restore: 'backup restore'
husksync_command_description: 'Manage the HuskSync plugin' save_cause_api: 'API'
userdata_command_description: 'View, manage & restore player userdata' save_cause_mpdb_migration: 'MPDB migration'
inventory_command_description: 'View & edit a player''s inventory' save_cause_legacy_migration: 'legacy migration'
enderchest_command_description: 'View & edit a player''s Ender Chest' save_cause_converted_from_v2: 'converted from v2'
up_to_date: '[HuskSync](#00fb9a bold) [| You are running the latest version of HuskSync (v%1%).](#00fb9a)'
update_available: '[HuskSync](#ff7e5e bold) [| A new version of HuskSync is available: v%1% (running: v%2%).](#ff7e5e)'
reload_complete: '[HuskSync](#00fb9a bold) [| Презаредихме конфигурацията и файловете със съобщения.](#00fb9a)\n[⚠ Ensure config files are up-to-date on all servers!](#00fb9a)\n[A restart is needed for config changes to take effect.](#00fb9a italic)'
system_status_header: '[HuskSync](#00fb9a bold) [| System status report:](#00fb9a)'
error_invalid_syntax: '[Грешка:](#ff3300) [Неправилен синтаксис. Използвайте:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&Click to suggest suggest_command=%1%)'
error_invalid_player: '[Грешка:](#ff3300) [Не можахме да открием играч с това име.](#ff7e5e)'
error_invalid_data: '[Error:](#ff3300) [Failed to unpack user data as the snapshot is invalid or corrupt.](#ff7e5e) [(Details…)](gray show_text=&7⚠ %1%)'
error_no_permission: '[Грешка:](#ff3300) [Нямате право да използвате тази команда](#ff7e5e)'
error_console_command_only: '[Грешка:](#ff3300) [Тази команда може да бъде използвана единствено през конзолата](#ff7e5e)'
error_in_game_command_only: 'Грешка: Тази команда може да бъде използвана само от играта.'
error_no_data_to_display: '[Грешка:](#ff3300) [Не можахме да открием никакви данни за потребителя, които да покажем.](#ff7e5e)'
error_invalid_version_uuid: '[Грешка:](#ff3300) [Не можахме да открием никакви потребителски данни за тази версия на това UUID.](#ff7e5e)'
husksync_command_description: 'Manage the HuskSync plugin'
userdata_command_description: 'View, manage & restore player userdata'
inventory_command_description: 'View & edit a player''s inventory'
enderchest_command_description: 'View & edit a player''s Ender Chest'

View File

@@ -1,49 +1,65 @@
synchronization_complete: '[⏵ Daten synchronisiert!](#00fb9a)' locales:
synchronization_failed: '[⏵ Ein Fehler ist beim Synchronisieren deiner Daten aufgetreten! Bitte kontaktiere einen Administrator.](#ff7e5e)' synchronization_complete: '[⏵ Daten synchronisiert!](#00fb9a)'
inventory_viewer_menu_title: '&0Inventar von %1%' synchronization_failed: '[⏵ Ein Fehler ist beim Synchronisieren deiner Daten aufgetreten! Bitte kontaktiere einen Administrator.](#ff7e5e)'
ender_chest_viewer_menu_title: '&0Endertruhe von %1%' inventory_viewer_menu_title: '&0Inventar von %1%'
inventory_viewer_opened: '[Du siehst den Schnappschuss des Inventares von](#00fb9a) [%1%](#00fb9a bold) [von ⌚ %2%](#00fb9a)' ender_chest_viewer_menu_title: '&0Endertruhe von %1%'
ender_chest_viewer_opened: '[Du siehst den Schnappschuss der Endertruhe von](#00fb9a) [%1%](#00fb9a bold) [von ⌚ %2%](#00fb9a)' inventory_viewer_opened: '[Du siehst den Schnappschuss des Inventares von](#00fb9a) [%1%](#00fb9a bold) [von ⌚ %2%](#00fb9a)'
data_update_complete: '[🔔 Deine Daten wurden aktualisiert!](#00fb9a)' ender_chest_viewer_opened: '[Du siehst den Schnappschuss der Endertruhe von](#00fb9a) [%1%](#00fb9a bold) [von ⌚ %2%](#00fb9a)'
data_update_failed: '[🔔 Ein Fehler ist beim Aktualisieren deiner Daten aufgetreten! Bitte kontaktiere einen Administrator.](#ff7e5e)' data_update_complete: '[🔔 Deine Daten wurden aktualisiert!](#00fb9a)'
user_registration_complete: '[⭐ User registration complete!](#00fb9a)' data_update_failed: '[🔔 Ein Fehler ist beim Aktualisieren deiner Daten aufgetreten! Bitte kontaktiere einen Administrator.](#ff7e5e)'
data_manager_title: '[Du siehst den Nutzerdaten-Schnappschuss](#00fb9a) [%1%](#00fb9a show_text=&7Versions-UUID:\n&8%2%) [für %3%](#00fb9a bold show_text=&7Spieler-UUID:\n&8%4%)[:](#00fb9a)' user_registration_complete: '[⭐ Benutzer-Registrierung abgeschlossen!](#00fb9a)'
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Versions-Zeitstempel:\n&8Zeitpunkt der Speicherung der Daten)' data_manager_title: '[Du siehst den Nutzerdaten-Schnappschuss](#00fb9a) [%1%](#00fb9a show_text=&7Versions-UUID:\n&8%2%) [für %3%](#00fb9a bold show_text=&7Spieler-UUID:\n&8%4%)[:](#00fb9a)'
data_manager_pinned: '[※ Schnappschuss angeheftet](#d8ff2b show_text=&7Angeheftet:\n&8Dieser Nutzerdaten-Schnappschuss wird nicht automatisch rotiert.)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Versions-Zeitstempel:\n&8Zeitpunkt der Speicherung der Daten)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Speicherungsgrund:\n&8Der Grund für das Speichern der Daten)' data_manager_pinned: '[※ Schnappschuss angeheftet](#d8ff2b show_text=&7Angeheftet:\n&8Dieser Nutzerdaten-Schnappschuss wird nicht automatisch rotiert.)'
data_manager_size: '[ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n' data_manager_cause: '[ %1%](#23a825-#36f539 show_text=&7Speicherungsgrund:\n&8Der Grund für das Speichern der Daten)'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Lebenspunkte) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Hungerpunkte) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP-Level) [🏹 %5%](dark_aqua show_text=&7Spielmodus)' data_manager_server: '[%1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name des Servers, auf dem die Daten gespeichert wurden)'
data_manager_advancements_statistics: '[⭐ Erfolge: %1%](color=#ffc43b-#f5c962 show_text=&7Erfolge in denen du Fortschritt gemacht hast:\n&8%2%) [⌛ Spielzeit: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Deine verbrachte Zeit im Spiel\n&8⚠ Basierend auf Spielstatistiken)\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Schnappschuss-Größe:\n&8Geschätzte Dateigröße des Schnappschusses (in KiB))\n'
data_manager_item_buttons: '[Sehen:](gray) [[🪣 Inventar…]](color=#a17b5f-#f5b98c show_text=&7Klicke zum Ansehen run_command=/inventory %1% %2%) [[⌀ Endertruhe…]](#b649c4-#d254ff show_text=&7Klicke zum Ansehen run_command=/enderchest %1% %2%)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Lebenspunkte) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Hungerpunkte) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP-Level) [🏹 %5%](dark_aqua show_text=&7Spielmodus)'
data_manager_management_buttons: '[Verwalten:](gray) [[❌ Löschen…]](#ff3300 show_text=&7Klicke, um diesen Nutzerdaten-Schnappschuss zu löschen.\n&8Dies betrifft nicht die aktuellen Nutzerdaten.\n&#ff3300&⚠ Dieser Schritt kann nicht rückgängig gemacht werden! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ Wiederherstellen…]](#00fb9a show_text=&7Klicke, um die Nutzerdaten wiederherzustellen.\n&8Dies wird die Nutzerdaten auf den Stand des Schnappschusses setzen.\n&#ff3300&⚠ Die aktuellen Nutzerdaten von %1% werden überschrieben! suggest_command=/husksync:userdata restore %1% %2%) [[※ Anheften/Loslösen…]](#d8ff2b show_text=&7Klicke, um diesen Nutzerdaten-Schnappschuss anzuheften oder loszulösen\n&8Angeheftete Nutzerdaten-Schnappschüsse werden nicht automatisch rotiert run_command=/userdata pin %1% %2%)' data_manager_advancements_statistics: '[⭐ Erfolge: %1%](color=#ffc43b-#f5c962 show_text=&7Erfolge in denen du Fortschritt gemacht hast:\n&8%2%) [⌛ Spielzeit: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Deine verbrachte Zeit im Spiel\n&8⚠ Basierend auf Spielstatistiken)\n'
data_manager_system_buttons: '[System:](gray) [[⏷ Daten-Dump…]](dark_gray show_text=&7Klicke, um diesen rohen Nutzerdaten-Schnappschuss in eine Datei zu speichern.\n&8Daten-Dumps können unter ~/plugins/HuskSync/dumps/ gefunden werden. run_command=/husksync:userdata dump %1% %2% file) [[☂ Web-Dump…]](dark_gray show_text=&7Klicke, um diesen rohen Nutzerdaten-Schnappschuss auf den mc-logs Service hochzuladen.\n&8Du erhältst dann eine URL, die die Daten enthält. run_command=/husksync:userdata dump %1% %2% web)' data_manager_item_buttons: '[Sehen:](gray) [[🪣 Inventar…]](color=#a17b5f-#f5b98c show_text=&7Klicke zum Ansehen run_command=/inventory %1% %2%) [[⌀ Endertruhe…]](#b649c4-#d254ff show_text=&7Klicke zum Ansehen run_command=/enderchest %1% %2%)'
data_manager_advancements_preview_remaining: 'und %1% weitere…' data_manager_management_buttons: '[Verwalten:](gray) [[❌ Löschen…]](#ff3300 show_text=&7Klicke, um diesen Nutzerdaten-Schnappschuss zu löschen.\n&8Dies betrifft nicht die aktuellen Nutzerdaten.\n&#ff3300&⚠ Dieser Schritt kann nicht rückgängig gemacht werden! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ Wiederherstellen…]](#00fb9a show_text=&7Klicke, um die Nutzerdaten wiederherzustellen.\n&8Dies wird die Nutzerdaten auf den Stand des Schnappschusses setzen.\n&#ff3300&⚠ Die aktuellen Nutzerdaten von %1% werden überschrieben! suggest_command=/husksync:userdata restore %1% %2%) [[※ Anheften/Loslösen…]](#d8ff2b show_text=&7Klicke, um diesen Nutzerdaten-Schnappschuss anzuheften oder loszulösen\n&8Angeheftete Nutzerdaten-Schnappschüsse werden nicht automatisch rotiert run_command=/userdata pin %1% %2%)'
data_list_title: '[Nutzerdaten-Schnappschüsse von %1%:](#00fb9a) [(%2%-%3% von](#00fb9a) [%4%](#00fb9a bold)[)](#00fb9a)\n' data_manager_system_buttons: '[System:](gray) [[⏷ Daten-Dump…]](dark_gray show_text=&7Klicke, um diesen rohen Nutzerdaten-Schnappschuss in eine Datei zu speichern.\n&8Daten-Dumps können unter ~/plugins/HuskSync/dumps/ gefunden werden. run_command=/husksync:userdata dump %1% %2% file) [[☂ Web-Dump…]](dark_gray show_text=&7Klicke, um diesen rohen Nutzerdaten-Schnappschuss auf den mc-logs Service hochzuladen.\n&8Du erhältst dann eine URL, die die Daten enthält. run_command=/husksync:userdata dump %1% %2% web)'
data_list_item: '[%1%](gray show_text=&7User Data Snapshot for %2%&8⚡ %4% run_command=/userdata view %2% %3%) [%5%](#d8ff2b show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962 show_text=&7Version timestamp:&7\n&8When the data was saved\n&8%7% run_command=/userdata view %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:&7\n&8Estimated file size of the snapshot (in KiB) run_command=/userdata view %2% %3%)' data_manager_advancements_preview_remaining: 'und %1% weitere…'
data_deleted: '[Nutzerdaten-Schnappschuss erfolgreich gelöscht](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [für](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)' data_list_title: '[Nutzerdaten-Schnappschüsse von %1%:](#00fb9a) [(%2%-%3% von](#00fb9a) [%4%](#00fb9a bold)[)](#00fb9a)\n'
data_restored: '[⏪ Erfgreich wiederhergestellt](#00fb9a) [Aktuelle Nutzerdaten des Schnappschusses von %1%](#00fb9a show_text=&7Spieler-UUID:\n&8%2%) [%3%.](#00fb9a show_text=&7Versions-UUID:\n&8%4%)' data_list_item: '[%1%](gray show_text=&7Nutzerdaten-Schnappschuss für %2%&8⚡ %4% run_command=/userdata view %2% %3%) [%5%](#d8ff2b show_text=&7Angeheftet:\n&8Angeheftete Schnappschüsse werden nicht automatisch rotiert. run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962 show_text=&7Versions-Zeitstempel:&7\n&8Zeitpunkt der Speicherung der Daten\n&8%7% run_command=/userdata view %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7Speicherungsgrund:\n&8Grund für das Speichern der Daten run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fa show_text=&7Schnappschuss-Größe:&7\n&8Geschätzte Dateigröße des Schnappschusses (in KiB) run_command=/userdata view %2% %3%)'
data_pinned: '[※ Nutzerdaten-Schnappschuss erfolgreich angepinnt](#00fb9a) [%1%](#00fb9a show_text=&7Versions-UUID:\n&8%2%) [für](#00fb9a) [%3%.](#00fb9a show_text=&7Spieler-UUID:\n&8%4%)' data_list_item_invalid: '[%1%](dark_gray show_text=&7User Data Snapshot for %2%\n&8⚡ %4% suggest_command=/userdata delete %2% %3%) [%5%](dark_gray show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. suggest_command=/userdata delete %2% %3%) [%6% ⚑ %8% ⏏ %9%](gray strikethrough show_text=&#ff3300&Invalid Data Snapshot\n&#ff7e5e&Click to delete\n\n&7⚠ %10% suggest_command=/userdata delete %2% %3%)'
data_unpinned: '[ Nutzerdaten-Schnappschuss erfolgreich losgelöst](#00fb9a) [%1%](#00fb9a show_text=&7Versions-UUID:\n&8%2%) [für](#00fb9a) [%3%.](#00fb9a show_text=&7Spieler-UUID:\n&8%4%)' data_deleted: '[ Nutzerdaten-Schnappschuss erfolgreich gelöscht](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [für](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)'
data_dumped: '[ Nutzerdaten-Schnappschuss %1% für %2% erfolgreich gedumpt nach:](#00fb9a) &7%3%' data_restored: '[⏪ Erfgreich wiederhergestellt](#00fb9a) [Aktuelle Nutzerdaten des Schnappschusses von %1%](#00fb9a show_text=&7Spieler-UUID:\n&8%2%) [%3%.](#00fb9a show_text=&7Versions-UUID:\n&8%4%)'
list_footer: '\n%1%[Seite](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%' data_pinned: '[※ Nutzerdaten-Schnappschuss erfolgreich angepinnt](#00fb9a) [%1%](#00fb9a show_text=&7Versions-UUID:\n&8%2%) [für](#00fb9a) [%3%.](#00fb9a show_text=&7Spieler-UUID:\n&8%4%)'
list_previous_page_button: '[◀](white show_text=&7Siehe vorherige Seite run_command=%2% %1%) ' data_unpinned: '[※ Nutzerdaten-Schnappschuss erfolgreich losgelöst](#00fb9a) [%1%](#00fb9a show_text=&7Versions-UUID:\n&8%2%) [für](#00fb9a) [%3%.](#00fb9a show_text=&7Spieler-UUID:\n&8%4%)'
list_next_page_button: ' [▶](white show_text=&7Siehe nächste Seite run_command=%2% %1%)' data_dumped: '[☂ Nutzerdaten-Schnappschuss %1% für %2% erfolgreich gedumpt nach:](#00fb9a) &7%3%'
list_page_jumpers: '(%1%)' list_footer: '\n%1%[Seite](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%'
list_page_jumper_button: '[%1%](show_text=&7Springe zu Seite %1% run_command=%2% %1%)' list_previous_page_button: '[◀](white show_text=&7Siehe vorherige Seite run_command=%2% %1%) '
list_page_jumper_current_page: '[%1%](#00fb9a)' list_next_page_button: ' [▶](white show_text=&7Siehe nächste Seite run_command=%2% %1%)'
list_page_jumper_separator: ' ' list_page_jumpers: '(%1%)'
list_page_jumper_group_separator: '' list_page_jumper_button: '[%1%](show_text=&7Springe zu Seite %1% run_command=%2% %1%)'
reload_complete: '[HuskSync](#00fb9a bold) [| Die Konfigurations- und Sprachdateien wurden neu geladen.](#00fb9a)\n[⚠ Ensure config files are up-to-date on all servers!](#00fb9a)\n[A restart is needed for config changes to take effect.](#00fb9a italic)' list_page_jumper_current_page: '[%1%](#00fb9a)'
up_to_date: '[HuskSync](#00fb9a bold) [| You are running the latest version of HuskSync (v%1%).](#00fb9a)' list_page_jumper_separator: ' '
update_available: '[HuskSync](#ff7e5e bold) [| A new version of HuskSync is available: v%1% (running: v%2%).](#ff7e5e)' list_page_jumper_group_separator: '…'
error_invalid_syntax: '[Fehler:](#ff3300) [Falsche Syntax. Nutze:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&Click to suggest suggest_command=%1%)' save_cause_disconnect: 'Server verlassen'
error_invalid_player: '[Fehler:](#ff3300) [Es konnte kein Spieler mit diesem Namen gefunden werden.](#ff7e5e)' save_cause_world_save: 'Welt gespeichert'
error_no_permission: '[Fehler:](#ff3300) [Du hast nicht die benötigten Berechtigungen um diesen Befehl auszuführen](#ff7e5e)' save_cause_death: 'Tod'
error_console_command_only: '[Fehler:](#ff3300) [Dieser Befehl kann nur über die Konsole ausgeführt werden.](#ff7e5e)' save_cause_server_shutdown: 'Server gestoppt'
error_in_game_command_only: 'Fehler: Dieser Befehl kann nur im Spiel genutzt werden.' save_cause_inventory_command: 'Inventar Befehl'
error_no_data_to_display: '[Fehler:](#ff3300) [Es konnten keine Nutzerdaten zum Anzeigen gefunden werden.](#ff7e5e)' save_cause_enderchest_command: 'Enderchest Befehl'
error_invalid_version_uuid: '[Fehler:](#ff3300) [Es konnten keine Nutzerdaten für diese Versions-UUID gefunden werden.](#ff7e5e)' save_cause_backup_restore: 'Backup wiederhergestellt'
husksync_command_description: 'Manage the HuskSync plugin' save_cause_api: 'API'
userdata_command_description: 'View, manage & restore player userdata' save_cause_mpdb_migration: 'MPDB Migration'
inventory_command_description: 'View & edit a player''s inventory' save_cause_legacy_migration: 'Legacy Migration'
enderchest_command_description: 'View & edit a player''s Ender Chest' save_cause_converted_from_v2: 'Import von v2'
reload_complete: '[HuskSync](#00fb9a bold) [| Die Konfigurations- und Sprachdateien wurden neu geladen.](#00fb9a)\n[⚠ Stelle sicher, dass die Konfigurationsdateien auf allen Servern aktuell sind!](#00fb9a)\n[Ein Neustart wird benötigt, damit Konfigurations-Änderungen wirkbar werden.](#00fb9a italic)'
up_to_date: '[HuskSync](#00fb9a bold) [| Du verwendest die neuste Version von HuskSync (v%1%).](#00fb9a)'
update_available: '[HuskSync](#ff7e5e bold) [| Eine neue Version von HuskSync ist verfügbar: v%1% (Aktuelle Version: v%2%).](#ff7e5e)'
system_status_header: '[HuskSync](#00fb9a bold) [| System status report:](#00fb9a)'
error_invalid_syntax: '[Fehler:](#ff3300) [Falsche Syntax. Nutze:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&Click to suggest suggest_command=%1%)'
error_invalid_player: '[Fehler:](#ff3300) [Es konnte kein Spieler mit diesem Namen gefunden werden.](#ff7e5e)'
error_invalid_data: '[Error:](#ff3300) [Failed to unpack user data as the snapshot is invalid or corrupt.](#ff7e5e) [(Details…)](gray show_text=&7⚠ %1%)'
error_no_permission: '[Fehler:](#ff3300) [Du hast nicht die benötigten Berechtigungen um diesen Befehl auszuführen](#ff7e5e)'
error_console_command_only: '[Fehler:](#ff3300) [Dieser Befehl kann nur über die Konsole ausgeführt werden.](#ff7e5e)'
error_in_game_command_only: 'Fehler: Dieser Befehl kann nur im Spiel genutzt werden.'
error_no_data_to_display: '[Fehler:](#ff3300) [Es konnten keine Nutzerdaten zum Anzeigen gefunden werden.](#ff7e5e)'
error_invalid_version_uuid: '[Fehler:](#ff3300) [Es konnten keine Nutzerdaten für diese Versions-UUID gefunden werden.](#ff7e5e)'
husksync_command_description: 'Das HuskSync-Plugin verwalten'
userdata_command_description: 'Nutzerdaten eines Spielers anzeigen, verwalten und wiederherstellen'
inventory_command_description: 'Inventar eines Spielers ansehen und bearbeiten'
enderchest_command_description: 'Endertruhe eines Spielers ansehen und bearbeiten'

View File

@@ -1,49 +1,65 @@
synchronization_complete: '[⏵ Data synchronized!](#00fb9a)' locales:
synchronization_failed: '[⏵ Failed to synchronize your data! Please contact an administrator.](#ff7e5e)' synchronization_complete: '[⏵ Data synchronized!](#00fb9a)'
inventory_viewer_menu_title: '&0%1%''s Inventory' synchronization_failed: '[⏵ Failed to synchronize your data! Please contact an administrator.](#ff7e5e)'
ender_chest_viewer_menu_title: '&0%1%''s Ender Chest' inventory_viewer_menu_title: '&0%1%''s Inventory'
inventory_viewer_opened: '[Viewing snapshot of](#00fb9a) [%1%](#00fb9a bold)[''s inventory as of ⌚ %2%](#00fb9a)' ender_chest_viewer_menu_title: '&0%1%''s Ender Chest'
ender_chest_viewer_opened: '[Viewing snapshot of](#00fb9a) [%1%](#00fb9a bold)[''s Ender Chest as of ⌚ %2%](#00fb9a)' inventory_viewer_opened: '[Viewing snapshot of](#00fb9a) [%1%](#00fb9a bold)[''s inventory as of ⌚ %2%](#00fb9a)'
data_update_complete: '[🔔 Your data has been updated!](#00fb9a)' ender_chest_viewer_opened: '[Viewing snapshot of](#00fb9a) [%1%](#00fb9a bold)[''s Ender Chest as of ⌚ %2%](#00fb9a)'
data_update_failed: '[🔔 Failed to update your data! Please contact an administrator.](#ff7e5e)' data_update_complete: '[🔔 Your data has been updated!](#00fb9a)'
user_registration_complete: '[⭐ User registration complete!](#00fb9a)' data_update_failed: '[🔔 Failed to update your data! Please contact an administrator.](#ff7e5e)'
data_manager_title: '[Viewing user data snapshot](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%](#00fb9a bold show_text=&7Player UUID:\n&8%4%)[:](#00fb9a)' user_registration_complete: '[⭐ User registration complete!](#00fb9a)'
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Version timestamp:\n&8When the data was saved)' data_manager_title: '[Viewing user data snapshot](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%](#00fb9a bold show_text=&7Player UUID:\n&8%4%)[:](#00fb9a)'
data_manager_pinned: '[※ Snapshot pinned](#d8ff2b show_text=&7Pinned:\n&8This user data snapshot won''t be automatically rotated.)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Version timestamp:\n&8When the data was saved)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved)' data_manager_pinned: '[※ Snapshot pinned](#d8ff2b show_text=&7Pinned:\n&8This user data snapshot won''t be automatically rotated.)'
data_manager_size: '[ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n' data_manager_cause: '[ %1%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved)'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Health points) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Hunger points) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP level) [🏹 %5%](dark_aqua show_text=&7Game mode)' data_manager_server: '[%1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_advancements_statistics: '[⭐ Advancements: %1%](color=#ffc43b-#f5c962 show_text=&7Advancements you have progress in:\n&8%2%) [⌛ Play Time: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7In-game play time\n&8⚠ Based on in-game statistics)\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n'
data_manager_item_buttons: '[View:](gray) [[🪣 Inventory…]](color=#a17b5f-#f5b98c show_text=&7Click to view run_command=/inventory %1% %2%) [[⌀ Ender Chest…]](#b649c4-#d254ff show_text=&7Click to view run_command=/enderchest %1% %2%)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Health points) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Hunger points) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP level) [🏹 %5%](dark_aqua show_text=&7Game mode)'
data_manager_management_buttons: '[Manage:](gray) [[❌ Delete…]](#ff3300 show_text=&7Click to delete this snapshot of user data.\n&8This will not affect the user''s current data.\n&#ff3300&⚠ This cannot be undone! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ Restore…]](#00fb9a show_text=&7Click to restore this user data.\n&8This will set the user''s data to this snapshot.\n&#ff3300&⚠ %1%''s current data will be overwritten! suggest_command=/husksync:userdata restore %1% %2%) [[※ Pin/Unpin…]](#d8ff2b show_text=&7Click to pin or unpin this user data snapshot\n&8Pinned snapshots won''t be automatically rotated run_command=/userdata pin %1% %2%)' data_manager_advancements_statistics: '[⭐ Advancements: %1%](color=#ffc43b-#f5c962 show_text=&7Advancements you have progress in:\n&8%2%) [⌛ Play Time: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7In-game play time\n&8⚠ Based on in-game statistics)\n'
data_manager_system_buttons: '[System:](gray) [[⏷ File Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to a file.\n&8Data dumps can be found in ~/plugins/HuskSync/dumps/ run_command=/husksync:userdata dump %1% %2% file) [[☂ Web Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to the mc-logs service\n&8You will be provided with a URL containing the data. run_command=/husksync:userdata dump %1% %2% web)' data_manager_item_buttons: '[View:](gray) [[🪣 Inventory…]](color=#a17b5f-#f5b98c show_text=&7Click to view run_command=/inventory %1% %2%) [[⌀ Ender Chest…]](#b649c4-#d254ff show_text=&7Click to view run_command=/enderchest %1% %2%)'
data_manager_advancements_preview_remaining: 'and %1% more…' data_manager_management_buttons: '[Manage:](gray) [[❌ Delete…]](#ff3300 show_text=&7Click to delete this snapshot of user data.\n&8This will not affect the user''s current data.\n&#ff3300&⚠ This cannot be undone! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ Restore…]](#00fb9a show_text=&7Click to restore this user data.\n&8This will set the user''s data to this snapshot.\n&#ff3300&⚠ %1%''s current data will be overwritten! suggest_command=/husksync:userdata restore %1% %2%) [[※ Pin/Unpin…]](#d8ff2b show_text=&7Click to pin or unpin this user data snapshot\n&8Pinned snapshots won''t be automatically rotated run_command=/userdata pin %1% %2%)'
data_list_title: '[%1%''s user data snapshots:](#00fb9a) [(%2%-%3% of](#00fb9a) [%4%](#00fb9a bold)[)](#00fb9a)\n' data_manager_system_buttons: '[System:](gray) [[⏷ File Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to a file.\n&8Data dumps can be found in ~/plugins/HuskSync/dumps/ run_command=/husksync:userdata dump %1% %2% file) [[☂ Web Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to the mc-logs service\n&8You will be provided with a URL containing the data. run_command=/husksync:userdata dump %1% %2% web)'
data_list_item: '[%1%](gray show_text=&7User Data Snapshot for %2%&8⚡ %4% run_command=/userdata view %2% %3%) [%5%](#d8ff2b show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962 show_text=&7Version timestamp:&7\n&8When the data was saved\n&8%7% run_command=/userdata view %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:&7\n&8Estimated file size of the snapshot (in KiB) run_command=/userdata view %2% %3%)' data_manager_advancements_preview_remaining: 'and %1% more…'
data_deleted: '[❌ Successfully deleted user data snapshot](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)' data_list_title: '[%1%''s user data snapshots:](#00fb9a) [(%2%-%3% of](#00fb9a) [%4%](#00fb9a bold)[)](#00fb9a)\n'
data_restored: '[⏪ Successfully restored](#00fb9a) [%1%](#00fb9a show_text=&7Player UUID:\n&8%2%)[''s current user data from snapshot](#00fb9a) [%3%.](#00fb9a show_text=&7Version UUID:\n&8%4%)' data_list_item: '[%1%](gray show_text=&7User Data Snapshot for %2%\n&8⚡ %4% run_command=/userdata view %2% %3%) [%5%](#d8ff2b show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962 show_text=&7Version timestamp:&7\n&8When the data was saved\n&8%7% run_command=/userdata view %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:&7\n&8Estimated file size of the snapshot (in KiB) run_command=/userdata view %2% %3%)'
data_pinned: '[※ Successfully pinned user data snapshot](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)' data_list_item_invalid: '[%1%](dark_gray show_text=&7User Data Snapshot for %2%\n&8⚡ %4% suggest_command=/userdata delete %2% %3%) [%5%](dark_gray show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. suggest_command=/userdata delete %2% %3%) [%6% ⚑ %8% ⏏ %9%](gray strikethrough show_text=&#ff3300&Invalid Data Snapshot\n&#ff7e5e&Click to delete\n\n&7⚠ %10% suggest_command=/userdata delete %2% %3%)'
data_unpinned: '[ Successfully unpinned user data snapshot](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)' data_deleted: '[ Successfully deleted user data snapshot](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)'
data_dumped: '[ Successfully dumped the user data snapshot %1% for %2% to:](#00fb9a) &7%3%' data_restored: '[ Successfully restored](#00fb9a) [%1%](#00fb9a show_text=&7Player UUID:\n&8%2%)[''s current user data from snapshot](#00fb9a) [%3%.](#00fb9a show_text=&7Version UUID:\n&8%4%)'
list_footer: '\n%1%[Page](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%' data_pinned: '[※ Successfully pinned user data snapshot](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)'
list_previous_page_button: '[◀](white show_text=&7View previous page run_command=%2% %1%) ' data_unpinned: '[※ Successfully unpinned user data snapshot](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)'
list_next_page_button: ' [▶](white show_text=&7View next page run_command=%2% %1%)' data_dumped: '[☂ Successfully dumped the user data snapshot %1% for %2% to:](#00fb9a) &7%3%'
list_page_jumpers: '(%1%)' list_footer: '\n%1%[Page](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%'
list_page_jumper_button: '[%1%](show_text=&7Jump to page %1% run_command=%2% %1%)' list_previous_page_button: '[◀](white show_text=&7View previous page run_command=%2% %1%) '
list_page_jumper_current_page: '[%1%](#00fb9a)' list_next_page_button: ' [▶](white show_text=&7View next page run_command=%2% %1%)'
list_page_jumper_separator: ' ' list_page_jumpers: '(%1%)'
list_page_jumper_group_separator: '' list_page_jumper_button: '[%1%](show_text=&7Jump to page %1% run_command=%2% %1%)'
up_to_date: '[HuskSync](#00fb9a bold) [| You are running the latest version of HuskSync (v%1%).](#00fb9a)' list_page_jumper_current_page: '[%1%](#00fb9a)'
update_available: '[HuskSync](#ff7e5e bold) [| A new version of HuskSync is available: v%1% (running: v%2%).](#ff7e5e)' list_page_jumper_separator: ' '
reload_complete: '[HuskSync](#00fb9a bold) [| Reloaded config and message files.](#00fb9a)\n[⚠ Ensure config files are up-to-date on all servers!](#00fb9a)\n[A restart is needed for config changes to take effect.](#00fb9a italic)' list_page_jumper_group_separator: '…'
error_invalid_syntax: '[Error:](#ff3300) [Incorrect syntax. Usage:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&Click to suggest suggest_command=%1%)' save_cause_disconnect: 'disconnect'
error_invalid_player: '[Error:](#ff3300) [Could not find a player by that name.](#ff7e5e)' save_cause_world_save: 'world save'
error_no_permission: '[Error:](#ff3300) [You do not have permission to execute this command](#ff7e5e)' save_cause_death: 'death'
error_console_command_only: '[Error:](#ff3300) [That command can only be run through console](#ff7e5e)' save_cause_server_shutdown: 'server shutdown'
error_in_game_command_only: 'Error: That command can only be used in-game.' save_cause_inventory_command: 'inventory command'
error_no_data_to_display: '[Error:](#ff3300) [Could not find any user data to display.](#ff7e5e)' save_cause_enderchest_command: 'enderchest command'
error_invalid_version_uuid: '[Error:](#ff3300) [Could not find any user data for that version UUID.](#ff7e5e)' save_cause_backup_restore: 'backup restore'
husksync_command_description: 'Manage the HuskSync plugin' save_cause_api: 'API'
userdata_command_description: 'View, manage & restore player userdata' save_cause_mpdb_migration: 'MPDB migration'
inventory_command_description: 'View & edit a player''s inventory' save_cause_legacy_migration: 'legacy migration'
enderchest_command_description: 'View & edit a player''s Ender Chest' save_cause_converted_from_v2: 'converted from v2'
up_to_date: '[HuskSync](#00fb9a bold) [| You are running the latest version of HuskSync (v%1%).](#00fb9a)'
update_available: '[HuskSync](#ff7e5e bold) [| A new version of HuskSync is available: v%1% (running: v%2%).](#ff7e5e)'
reload_complete: '[HuskSync](#00fb9a bold) [| Reloaded config and message files.](#00fb9a)\n[⚠ Ensure config files are up-to-date on all servers!](#00fb9a)\n[A restart is needed for config changes to take effect.](#00fb9a italic)'
system_status_header: '[HuskSync](#00fb9a bold) [| System status report:](#00fb9a)'
error_invalid_syntax: '[Error:](#ff3300) [Incorrect syntax. Usage:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&Click to suggest suggest_command=%1%)'
error_invalid_player: '[Error:](#ff3300) [Could not find a player by that name.](#ff7e5e)'
error_invalid_data: '[Error:](#ff3300) [Failed to unpack user data as the snapshot is invalid or corrupt.](#ff7e5e) [(Details…)](gray show_text=&7⚠ %1%)'
error_no_permission: '[Error:](#ff3300) [You do not have permission to execute this command](#ff7e5e)'
error_console_command_only: '[Error:](#ff3300) [That command can only be run through console](#ff7e5e)'
error_in_game_command_only: 'Error: That command can only be used in-game.'
error_no_data_to_display: '[Error:](#ff3300) [Could not find any user data to display.](#ff7e5e)'
error_invalid_version_uuid: '[Error:](#ff3300) [Could not find any user data for that version UUID.](#ff7e5e)'
husksync_command_description: 'Manage the HuskSync plugin'
userdata_command_description: 'View, manage & restore player userdata'
inventory_command_description: 'View & edit a player''s inventory'
enderchest_command_description: 'View & edit a player''s Ender Chest'

View File

@@ -1,49 +1,65 @@
synchronization_complete: '[⏵ ¡Datos sincronizados!](#00fb9a)' locales:
synchronization_failed: '[⏵ Fallo al sincronizar los datos, por favor, contacte con un administrador.](#ff7e5e)' synchronization_complete: '[⏵ ¡Datos sincronizados!](#00fb9a)'
inventory_viewer_menu_title: '&0%1% Inventario de:' synchronization_failed: '[⏵ Fallo al sincronizar los datos, por favor, contacte con un administrador.](#ff7e5e)'
ender_chest_viewer_menu_title: '&0%1% Enderchest de:' inventory_viewer_menu_title: '&0%1% Inventario de:'
inventory_viewer_opened: '[Viendo una snapshot de](#00fb9a) [%1%](#00fb9a bold) [Inventario a partir de ⌚ %2%](#00fb9a)' ender_chest_viewer_menu_title: '&0%1% Enderchest de:'
ender_chest_viewer_opened: '[Viendo una snapshot de](#00fb9a) [%1%](#00fb9a bold) [Enderchest a partir de ⌚ %2%](#00fb9a)' inventory_viewer_opened: '[Viendo una snapshot de](#00fb9a) [%1%](#00fb9a bold) [Inventario a partir de ⌚ %2%](#00fb9a)'
data_update_complete: '[🔔 ¡Tus datos han sido actualizados!](#00fb9a)' ender_chest_viewer_opened: '[Viendo una snapshot de](#00fb9a) [%1%](#00fb9a bold) [Enderchest a partir de ⌚ %2%](#00fb9a)'
data_update_failed: '[🔔 Error al actualizar tus datos, por favor, contacte con un administrador.](#ff7e5e)' data_update_complete: '[🔔 ¡Tus datos han sido actualizados!](#00fb9a)'
user_registration_complete: '[⭐ User registration complete!](#00fb9a)' data_update_failed: '[🔔 Error al actualizar tus datos, por favor, contacte con un administrador.](#ff7e5e)'
data_manager_title: '[Viendo una snapshot sobre la informacion del jugador](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%](#00fb9a bold show_text=&7Player UUID:\n&8%4%)[:](#00fb9a)' user_registration_complete: '[⭐ User registration complete!](#00fb9a)'
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Version del registro:\n&8Cuando los datos se han guardado)' data_manager_title: '[Viendo una snapshot sobre la informacion del jugador](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%](#00fb9a bold show_text=&7Player UUID:\n&8%4%)[:](#00fb9a)'
data_manager_pinned: '[※ Snapshot anclada](#d8ff2b show_text=&Anclado:\n&8La informacion de este jugador no se rotará automaticamente.)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Version del registro:\n&8Cuando los datos se han guardado)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Motivo del guardado:\n&8Lo que ha causado que se guarde)' data_manager_pinned: '[※ Snapshot anclada](#d8ff2b show_text=&Anclado:\n&8La informacion de este jugador no se rotará automaticamente.)'
data_manager_size: '[ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n' data_manager_cause: '[ %1%](#23a825-#36f539 show_text=&7Motivo del guardado:\n&8Lo que ha causado que se guarde)'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Puntos de vida) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Puntos de hambre) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7Nivel de exp) [🏹 %5%](dark_aqua show_text=&7Gamemode)' data_manager_server: '[%1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_advancements_statistics: '[⭐ Logros: %1%](color=#ffc43b-#f5c962 show_text=&7Logros que has conseguido:\n&8%2%) [⌛ Tiempo de juego: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7In-game play time\n&8⚠ Based on in-game statistics)\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n'
data_manager_item_buttons: '[View:](gray) [[🪣 Inventario…]](color=#a17b5f-#f5b98c show_text=&7Click para ver run_command=/inventory %1% %2%) [[⌀ Enderchest…]](#b649c4-#d254ff show_text=&7Click para ver run_command=/enderchest %1% %2%)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Puntos de vida) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Puntos de hambre) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7Nivel de exp) [🏹 %5%](dark_aqua show_text=&7Gamemode)'
data_manager_management_buttons: '[Manage:](gray) [[❌ Borrar…]](#ff3300 show_text=&7Click para borrar la snapshot del usuario.\n&8Esto no afectará a la informacion actual del jugador.\n&#ff3300&⚠ ¡Esto no se puede deshacer! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ Restaurar…]](#00fb9a show_text=&7Click para restaurar la informacion de este usuario.\n&8Esto hará que la informacion actual cambie por esta snapshot.\n&#ff3300&⚠ %1% la informacion actual será sustituida! suggest_command=/husksync:userdata restore %1% %2%) [[※ Pin/Unpin…]](#d8ff2b show_text=&7Click para anclar/desanclar esta snapshot\n&8Las snapshot ancladas no seran rotadas automaticamente run_command=/userdata pin %1% %2%)' data_manager_advancements_statistics: '[⭐ Logros: %1%](color=#ffc43b-#f5c962 show_text=&7Logros que has conseguido:\n&8%2%) [⌛ Tiempo de juego: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7In-game play time\n&8⚠ Based on in-game statistics)\n'
data_manager_system_buttons: '[System:](gray) [[⏷ File Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to a file.\n&8Data dumps can be found in ~/plugins/HuskSync/dumps/ run_command=/husksync:userdata dump %1% %2% file) [[☂ Web Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to the mc-logs service\n&8You will be provided with a URL containing the data. run_command=/husksync:userdata dump %1% %2% web)' data_manager_item_buttons: '[View:](gray) [[🪣 Inventario…]](color=#a17b5f-#f5b98c show_text=&7Click para ver run_command=/inventory %1% %2%) [[⌀ Enderchest…]](#b649c4-#d254ff show_text=&7Click para ver run_command=/enderchest %1% %2%)'
data_manager_advancements_preview_remaining: 'y %1% más…' data_manager_management_buttons: '[Manage:](gray) [[❌ Borrar…]](#ff3300 show_text=&7Click para borrar la snapshot del usuario.\n&8Esto no afectará a la informacion actual del jugador.\n&#ff3300&⚠ ¡Esto no se puede deshacer! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ Restaurar…]](#00fb9a show_text=&7Click para restaurar la informacion de este usuario.\n&8Esto hará que la informacion actual cambie por esta snapshot.\n&#ff3300&⚠ %1% la informacion actual será sustituida! suggest_command=/husksync:userdata restore %1% %2%) [[※ Pin/Unpin…]](#d8ff2b show_text=&7Click para anclar/desanclar esta snapshot\n&8Las snapshot ancladas no seran rotadas automaticamente run_command=/userdata pin %1% %2%)'
data_list_title: '[%1%''s user data snapshots:](#00fb9a) [(%2%-%3% of](#00fb9a) [%4%](#00fb9a bold)[)](#00fb9a)\n' data_manager_system_buttons: '[System:](gray) [[⏷ File Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to a file.\n&8Data dumps can be found in ~/plugins/HuskSync/dumps/ run_command=/husksync:userdata dump %1% %2% file) [[☂ Web Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to the mc-logs service\n&8You will be provided with a URL containing the data. run_command=/husksync:userdata dump %1% %2% web)'
data_list_item: '[%1%](gray show_text=&7User Data Snapshot for %2%&8⚡ %4% run_command=/userdata view %2% %3%) [%5%](#d8ff2b show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962 show_text=&7Version timestamp:&7\n&8When the data was saved\n&8%7% run_command=/userdata view %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:&7\n&8Estimated file size of the snapshot (in KiB) run_command=/userdata view %2% %3%)' data_manager_advancements_preview_remaining: 'y %1% más…'
data_deleted: '[❌ Se ha eliminado correctamente la snapshot del usuario](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)' data_list_title: '[%1%''s user data snapshots:](#00fb9a) [(%2%-%3% of](#00fb9a) [%4%](#00fb9a bold)[)](#00fb9a)\n'
data_restored: '[⏪ Restaurado correctamente](#00fb9a) [%1%](#00fb9a show_text=&7UUID del jugador:\n&8%2%)[Informacion actual de la snapshot del jugador](#00fb9a) [%3%.](#00fb9a show_text=&7Version UUID:\n&8%4%)' data_list_item: '[%1%](gray show_text=&7User Data Snapshot for %2%\n&8⚡ %4% run_command=/userdata view %2% %3%) [%5%](#d8ff2b show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962 show_text=&7Version timestamp:&7\n&8When the data was saved\n&8%7% run_command=/userdata view %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:&7\n&8Estimated file size of the snapshot (in KiB) run_command=/userdata view %2% %3%)'
data_pinned: '[※ Se ha anclado perfectamente la snapshot del jugador](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7UUID del usuario:\n&8%4%)' data_list_item_invalid: '[%1%](dark_gray show_text=&7User Data Snapshot for %2%\n&8⚡ %4% suggest_command=/userdata delete %2% %3%) [%5%](dark_gray show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. suggest_command=/userdata delete %2% %3%) [%6% ⚑ %8% ⏏ %9%](gray strikethrough show_text=&#ff3300&Invalid Data Snapshot\n&#ff7e5e&Click to delete\n\n&7⚠ %10% suggest_command=/userdata delete %2% %3%)'
data_unpinned: '[ Se ha desanclado perfectamente la snapshot del jugador](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7UUID del usuario:\n&8%4%)' data_deleted: '[ Se ha eliminado correctamente la snapshot del usuario](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)'
data_dumped: '[☂ Successfully dumped the user data snapshot %1% for %2% to:](#00fb9a) &7%3%' data_restored: '[⏪ Restaurado correctamente](#00fb9a) [%1%](#00fb9a show_text=&7UUID del jugador:\n&8%2%)[Informacion actual de la snapshot del jugador](#00fb9a) [%3%.](#00fb9a show_text=&7Version UUID:\n&8%4%)'
list_footer: '\n%1%[Page](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%' data_pinned: '[※ Se ha anclado perfectamente la snapshot del jugador](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7UUID del usuario:\n&8%4%)'
list_previous_page_button: '[◀](white show_text=&7View previous page run_command=%2% %1%) ' data_unpinned: '[※ Se ha desanclado perfectamente la snapshot del jugador](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7UUID del usuario:\n&8%4%)'
list_next_page_button: ' [▶](white show_text=&7View next page run_command=%2% %1%)' data_dumped: '[☂ Successfully dumped the user data snapshot %1% for %2% to:](#00fb9a) &7%3%'
list_page_jumpers: '(%1%)' list_footer: '\n%1%[Page](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%'
list_page_jumper_button: '[%1%](show_text=&7Jump to page %1% run_command=%2% %1%)' list_previous_page_button: '[◀](white show_text=&7View previous page run_command=%2% %1%) '
list_page_jumper_current_page: '[%1%](#00fb9a)' list_next_page_button: ' [▶](white show_text=&7View next page run_command=%2% %1%)'
list_page_jumper_separator: ' ' list_page_jumpers: '(%1%)'
list_page_jumper_group_separator: '' list_page_jumper_button: '[%1%](show_text=&7Jump to page %1% run_command=%2% %1%)'
up_to_date: '[HuskSync](#00fb9a bold) [| You are running the latest version of HuskSync (v%1%).](#00fb9a)' list_page_jumper_current_page: '[%1%](#00fb9a)'
update_available: '[HuskSync](#ff7e5e bold) [| A new version of HuskSync is available: v%1% (running: v%2%).](#ff7e5e)' list_page_jumper_separator: ' '
reload_complete: '[HuskSync](#00fb9a bold) [| Recargada la configuración y los archivos de lenguaje.](#00fb9a)\n[⚠ Ensure config files are up-to-date on all servers!](#00fb9a)\n[A restart is needed for config changes to take effect.](#00fb9a italic)' list_page_jumper_group_separator: '…'
error_invalid_syntax: '[Error:](#ff3300) [Sintanxis incorrecta. Usa:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&Click to suggest suggest_command=%1%)' save_cause_disconnect: 'disconnect'
error_invalid_player: '[Error:](#ff3300) [No se ha podido encontrar un jugador con ese nombre.](#ff7e5e)' save_cause_world_save: 'world save'
error_no_permission: '[Error:](#ff3300) [No tienes permisos para ejecutar este comando.](#ff7e5e)' save_cause_death: 'death'
error_console_command_only: '[Error:](#ff3300) [Este comando solo se puede ejecutar desde la consola.](#ff7e5e)' save_cause_server_shutdown: 'server shutdown'
error_in_game_command_only: 'Error: Ese comando solo se puede utilizar desde el juego.' save_cause_inventory_command: 'inventory command'
error_no_data_to_display: '[Error:](#ff3300) [No se ha podido encontrar informacion sobre el jugador.](#ff7e5e)' save_cause_enderchest_command: 'enderchest command'
error_invalid_version_uuid: '[Error:](#ff3300) [No se ha podido encontrar informacion sobre la UUID de ese jugador.](#ff7e5e)' save_cause_backup_restore: 'backup restore'
husksync_command_description: 'Manage the HuskSync plugin' save_cause_api: 'API'
userdata_command_description: 'View, manage & restore player userdata' save_cause_mpdb_migration: 'MPDB migration'
inventory_command_description: 'View & edit a player''s inventory' save_cause_legacy_migration: 'legacy migration'
enderchest_command_description: 'View & edit a player''s Ender Chest' save_cause_converted_from_v2: 'converted from v2'
up_to_date: '[HuskSync](#00fb9a bold) [| You are running the latest version of HuskSync (v%1%).](#00fb9a)'
update_available: '[HuskSync](#ff7e5e bold) [| A new version of HuskSync is available: v%1% (running: v%2%).](#ff7e5e)'
reload_complete: '[HuskSync](#00fb9a bold) [| Recargada la configuración y los archivos de lenguaje.](#00fb9a)\n[⚠ Ensure config files are up-to-date on all servers!](#00fb9a)\n[A restart is needed for config changes to take effect.](#00fb9a italic)'
system_status_header: '[HuskSync](#00fb9a bold) [| System status report:](#00fb9a)'
error_invalid_syntax: '[Error:](#ff3300) [Sintanxis incorrecta. Usa:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&Click to suggest suggest_command=%1%)'
error_invalid_player: '[Error:](#ff3300) [No se ha podido encontrar un jugador con ese nombre.](#ff7e5e)'
error_invalid_data: '[Error:](#ff3300) [Failed to unpack user data as the snapshot is invalid or corrupt.](#ff7e5e) [(Details…)](gray show_text=&7⚠ %1%)'
error_no_permission: '[Error:](#ff3300) [No tienes permisos para ejecutar este comando.](#ff7e5e)'
error_console_command_only: '[Error:](#ff3300) [Este comando solo se puede ejecutar desde la consola.](#ff7e5e)'
error_in_game_command_only: 'Error: Ese comando solo se puede utilizar desde el juego.'
error_no_data_to_display: '[Error:](#ff3300) [No se ha podido encontrar informacion sobre el jugador.](#ff7e5e)'
error_invalid_version_uuid: '[Error:](#ff3300) [No se ha podido encontrar informacion sobre la UUID de ese jugador.](#ff7e5e)'
husksync_command_description: 'Manage the HuskSync plugin'
userdata_command_description: 'View, manage & restore player userdata'
inventory_command_description: 'View & edit a player''s inventory'
enderchest_command_description: 'View & edit a player''s Ender Chest'

View File

@@ -0,0 +1,65 @@
locales:
synchronization_complete: '[⏵ Données synchronisées!](#00fb9a)'
synchronization_failed: '[⏵ Impossible de synchroniser vos données! Veuillez contacter un administrateur.](#ff7e5e)'
inventory_viewer_menu_title: '&0Inventaire de %1%'
ender_chest_viewer_menu_title: '&0Coffre de l''Ender de %1%'
inventory_viewer_opened: '[Visualisation de l''instantané de](#00fb9a) [%1%](#00fb9a bold)[''s inventaire à partir de ⌚ %2%](#00fb9a)'
ender_chest_viewer_opened: '[Visualisation de l''instantané du](#00fb9a) [%1%](#00fb9a bold)[''s coffre de l''Ender à partir de ⌚ %2%](#00fb9a)'
data_update_complete: '[🔔 Vos données ont été mises à jour!](#00fb9a)'
data_update_failed: '[🔔 Échec de la mise à jour de vos données! Veuillez contacter un administrateur.](#ff7e5e)'
user_registration_complete: '[⭐ Inscription de l''utilisateur complète!](#00fb9a)'
data_manager_title: '[Visualisation de l''instantané des données utilisateur](#00fb9a) [%1%](#00fb9a show_text=&7UUID de la version:\n&8%2%) [pour](#00fb9a) [%3%](#00fb9a bold show_text=&7UUID du joueur:\n&8%4%)[:](#00fb9a)'
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Horodatage de la version:\n&8Quand les données ont été enregistrées)'
data_manager_pinned: '[※ Instantané épinglé](#d8ff2b show_text=&7Épinglé:\n&8Cet instantané des données utilisateur ne sera pas automatiquement supprimé.)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Cause de la sauvegarde:\n&8Ce qui a causé l''enregistrement des données)'
data_manager_server: '[☁ %1%](#ff87b3-#f5538e show_text=&7Serveur:\n&8Nom du serveur sur lequel les données ont été enregistrées)'
data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Taille de l''instantané:\n&8Taille du fichier estimée de l''instantané (en KiB))\n'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Points de vie) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Points de faim) [ʟᴠ](green)[.](gray)[%4%](greenshow_text=&7Niveau XP) [🏹 %5%](dark_aqua show_text=&7Mode de jeu)'
data_manager_advancements_statistics: '[⭐ Avancements: %1%](color=#ffc43b-#f5c962show_text=&7Avancements dans lesquels vous avez progressé:\n&8%2%) [⌛ Temps de jeu: %3%ʜʀs](color=#62a9f5-#7ab8fashow_text=&7Temps de jeu en jeu\n&8⚠ Basé sur les statistiques en jeu)\n'
data_manager_item_buttons: '[Voir:](gray) [[🪣 Inventaire…]](color=#a17b5f-#f5b98cshow_text=&7Cliquez pour voir run_command=/inventory %1% %2%) [[⌀ Coffre de l''Ender…]](#b649c4-#d254ffshow_text=&7Cliquez pour voir run_command=/enderchest %1% %2%)'
data_manager_management_buttons: '[Gérer:](gray) [[❌ Supprimer…]](#ff3300 show_text=&7Cliquezpour supprimer cet instantané des données utilisateur.\n&8Cela n''affectera pas les données actuelles de l''utilisateur.\n&#ff3300&⚠ Cette action est irréversible! suggest_command=/husksync:userdata delete%1% %2%) [[⏪ Restaurer…]](#00fb9a show_text=&7Cliquez pour restaurer ces données utilisateur.\n&8Cela définira les données de l''utilisateur sur cet instantané.\n&#ff3300&⚠ Les données actuelles de %1% serontremplacées! suggest_command=/husksync:userdata restore %1% %2%) [[※ Épingler/Détacher…]](#d8ff2bshow_text=&7Cliquez pour épingler ou détacher cet instantané des données utilisateur\n&8Les instantanés épinglés ne seront pas automatiquement supprimés run_command=/userdata pin %1% %2%)'
data_manager_system_buttons: '[Système:](gray) [[⏷ Export fichier…]](dark_gray show_text=&7Cliquezpour exporter cet instantané des données utilisateur à un fichier.\n&8Les exports de données peuvent être trouvés dans ~/plugins/HuskSync/dumps/run_command=/husksync:userdata dump %1% %2% file) [[☂ Export web…]](dark_grayshow_text=&7Cliquez pour exporter cet instantané des données utilisateur au service mc-logs\n&8Vousobtiendrez une URL contenant les données. run_command=/husksync:userdatadump %1% %2% web)'
data_manager_advancements_preview_remaining: 'et %1% autres…'
data_list_title: '[Les instantanés des données utilisateur de %1%:](#00fb9a) [(%2%-%3% sur](#00fb9a)[%4%](#00fb9a bold)[)](#00fb9a)\n'
data_list_item: '[%1%](gray show_text=&7Instantané des données utilisateur pour %2%\n&8⚡ %4% run_command=/userdataview %2% %3%) [%5%](#d8ff2b show_text=&7Épinglé:\n&8Les instantanés épinglés ne serontpas automatiquement supprimés. run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962show_text=&7Horodatage de la version:&7\n&8Quand les données ont été enregistrées\n&8%7% run_command=/userdataview %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7Cause de la sauvegarde:\n&8Ce qui a causél''enregistrement des données run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fashow_text=&7Taille de l''instantané:&7\n&8Taille du fichier estimée de l''instantané (en KiB) run_command=/userdataview %2% %3%)'
data_list_item_invalid: '[%1%](dark_gray show_text=&7Instantané des données utilisateur pour %2%\n&8⚡%4% suggest_command=/userdata delete %2% %3%) [%5%](dark_gray show_text=&7Épinglé:\n&8Lesinstantanés épinglés ne seront pas automatiquement supprimés. suggest_command=/userdata delete %2%%3%) [%6% ⚑ %8% ⏏ %9%](gray strikethrough show_text=&#ff3300&Instantané des donnéesinvalide\n&#ff7e5e&Cliquez pour supprimer\n\n&7⚠ %10% suggest_command=/userdata delete%2% %3%)'
data_deleted: '[❌ Instantané des données utilisateur supprimé avec succès](#00fb9a) [%1%](#00fb9ashow_text=&7UUID de la version:\n&8%2%) [pour](#00fb9a) [%3%.](#00fb9a show_text=&7UUID du joueur:\n&8%4%)'
data_restored: '[⏪ Données utilisateur actuelles de %1% restaurées avec succès à partir de l''instantané](#00fb9a) [%3%.](#00fb9a show_text=&7UUID de la version:\n&8%4%)'
data_pinned: '[※ Instantané des données utilisateur épinglé avec succès](#00fb9a) [%1%](#00fb9ashow_text=&7UUID de la version:\n&8%2%) [pour](#00fb9a) [%3%.](#00fb9a show_text=&7UUID du joueur:\n&8%4%)'
data_unpinned: '[※ Instantané des données utilisateur détaché avec succès](#00fb9a) [%1%](#00fb9ashow_text=&7UUID de la version:\n&8%2%) [pour](#00fb9a) [%3%.](#00fb9a show_text=&7UUID du joueur:\n&8%4%)'
data_dumped: '[☂ Dump de l''instantané des données utilisateur %1% pour %2% à:](#00fb9a)&7%3%'
list_footer: '\n%1%[Page](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%'
list_previous_page_button: '[◀](white show_text=&7Voir la page précédente run_command=%2%%1%) '
list_next_page_button: ' [▶](white show_text=&7Voir la page suivante run_command=%2% %1%)'
list_page_jumpers: '(%1%)'
list_page_jumper_button: '[%1%](show_text=&7Aller à la page %1% run_command=%2% %1%)'
list_page_jumper_current_page: '[%1%](#00fb9a)'
list_page_jumper_separator: ' '
list_page_jumper_group_separator: '…'
save_cause_disconnect: 'déconnexion'
save_cause_world_save: 'sauvegarde du monde'
save_cause_death: 'mort'
save_cause_server_shutdown: 'arrêt du serveur'
save_cause_inventory_command: 'commande d''inventaire'
save_cause_enderchest_command: 'commande du coffre de l''Ender'
save_cause_backup_restore: 'restauration de sauvegarde'
save_cause_api: 'API'
save_cause_mpdb_migration: 'migration MPDB'
save_cause_legacy_migration: 'migration legacy'
save_cause_converted_from_v2: 'converti de v2'
up_to_date: '[HuskSync](#00fb9a bold) [| Vous utilisez la dernière version de HuskSync(v%1%).](#00fb9a)'
update_available: '[HuskSync](#ff7e5e bold) [| Une nouvelle version de HuskSync est disponible:v%1% (version actuelle: v%2%).](#ff7e5e)'
reload_complete: '[HuskSync](#00fb9a bold) [| Config et messages rechargés.](#00fb9a)\n[⚠Assurez-vous que les fichiers de configuration sont à jour sur tous les serveurs!](#00fb9a)\n[Un redémarrage est nécessairepour que les modifications de configuration prennent effet.](#00fb9a italic)'
system_status_header: '[HuskSync](#00fb9a bold) [| Rapport d''état du système:](#00fb9a)'
error_invalid_syntax: '[Erreur:](#ff3300) [Syntaxe incorrecte. Utilisation:](#ff7e5e) [%1%](#ff7e5eitalic show_text=&#ff7e5e&Cliquez pour suggérer suggest_command=%1%)'
error_invalid_player: '[Erreur:](#ff3300) [Impossible de trouver un joueur avec ce nom.](#ff7e5e)'
error_invalid_data: '[Erreur:](#ff3300) [Impossible de déballer les données de l''instantané car elles sont invalides ou corrompues.](#ff7e5e) [(Détails…)](gray show_text=&7⚠ %1%)'
error_no_permission: '[Erreur:](#ff3300) [Vous n''avez pas la permission d''exécuter cettecommande](#ff7e5e)'
error_console_command_only: '[Erreur:](#ff3300) [Cette commande peut seulement être exécutée via la console](#ff7e5e)'
error_in_game_command_only: 'Erreur: Cette commande peut uniquement être utilisée en jeu.'
error_no_data_to_display: '[Erreur:](#ff3300) [Impossible de trouver des données utilisateur à afficher.](#ff7e5e)'
error_invalid_version_uuid: '[Erreur:](#ff3300) [Impossible de trouver des données utilisateur pour cet UUID de version.](#ff7e5e)'
husksync_command_description: 'Gérer le plugin HuskSync'
userdata_command_description: 'Voir, gérer & restaurer les données utilisateur des joueurs'
inventory_command_description: 'Voir & modifier l''inventaire d''un joueur'
enderchest_command_description: 'Voir & modifier le Coffre de l''Ender d''un joueur'

View File

@@ -0,0 +1,65 @@
locales:
synchronization_complete: '[⏵ Data disinkronkan!](#00fb9a)'
synchronization_failed: '[⏵ Gagal menyinkronkan datamu! Mohon hubungi administrator.](#ff7e5e)'
inventory_viewer_menu_title: '&0Inventaris milik %1%'
ender_chest_viewer_menu_title: '&0Peti Ender milik %1%'
inventory_viewer_opened: '[Melihat cuplikan inventaris milik](#00fb9a) [%1%](#00fb9a bold)[pada ⌚ %2%](#00fb9a)'
ender_chest_viewer_opened: '[Melihat cuplikan Peti Ender milik](#00fb9a) [%1%](#00fb9a bold)[pada ⌚ %2%](#00fb9a)'
data_update_complete: '[🔔 Datamu telah diperbarui!](#00fb9a)'
data_update_failed: '[🔔 Gagal memperbarui datamu! Mohon hubungi administrator.](#ff7e5e)'
user_registration_complete: '[⭐ Pendaftaran pengguna selesai!](#00fb9a)'
data_manager_title: '[Melihat cuplikan data pengguna](#00fb9a) [%1%](#00fb9a show_text=&7Versi UUID:\n&8%2%) [untuk](#00fb9a) [%3%](#00fb9a bold show_text=&7UUID pemain:\n&8%4%)[:](#00fb9a)'
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Versi stempel waktu:\n&8Ketika data disimpan)'
data_manager_pinned: '[※ Cuplikan disematkan](#d8ff2b show_text=&7Disematkan:\n&8Cuplikan data pengguna ini tidak akan diputar secara otomatis.)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Penyebab penyimpanan:\n&8Apa yang menyebabkan data disimpan)'
data_manager_server: '[☁ %1%](#ff87b3-#f5538e show_text=&7Server:\n&8Nama server tempat data disimpan)'
data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Ukuran cuplikan:\n&8Perkiraan ukuran file cuplikan (dalam KiB))\n'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Poin kesehatan) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Poin kelaparan) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7Level XP) [🏹 %5%](dark_aqua show_text=&7Mode game)'
data_manager_advancements_statistics: '[⭐ Kemajuan: %1%](color=#ffc43b-#f5c962 show_text=&7Kemajuan yang telah kamu capai:\n&8%2%) [⌛ Waktu Bermain: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Waktu bermain dalam game\n&8⚠ Statistik berdasarkan dalam game)\n'
data_manager_item_buttons: '[Lihat:](gray) [[🪣 Inventaris…]](color=#a17b5f-#f5b98c show_text=&7Klik untuk lihat run_command=/inventory %1% %2%) [[⌀ Peti Ender…]](#b649c4-#d254ff show_text=&7Klik untuk lihat run_command=/enderchest %1% %2%)'
data_manager_management_buttons: '[Kelola:](gray) [[❌ Hapus…]](#ff3300 show_text=&7Klik untuk menghapus cuplikan data pengguna ini.\n&8Ini tidak akan berdampak pada data pengguna saat ini.\n&#ff3300&⚠ Hal ini tidak dapat dibatalkan! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ Pulihkan…]](#00fb9a show_text=&7Klik untuk memulihkan data pengguna ini.\n&8Ini akan mengatur data pengguna ke cuplikan ini.\n&#ff3300&⚠ Data %1% saat ini akan ditimpa! suggest_command=/husksync:userdata restore %1% %2%) [[※ Sematkan/Tidak disematkan…]](#d8ff2b show_text=&7Klik untuk menyematkan atau tidak cuplikan data pengguna ini\n&8Cuplikan yang disematkan tidak akan diputar otomatis run_command=/userdata pin %1% %2%)'
data_manager_system_buttons: '[Sistem:](gray) [[⏷ Pembuangan File…]](dark_gray show_text=&7Klik untuk membuang cuplikan data mentah pengguna ini ke sebuah file.\n&8Data yang dibuang dapat ditemukan di ~/plugins/HuskSync/dumps/ run_command=/husksync:userdata dump %1% %2% file) [[☂ Pembuangan Web…]](dark_gray show_text=&7Klik untuk membuang cuplikan data mentah pengguna ini ke layanan mc-logs\n&8Kamu akan diberikan URL yang berisi data. run_command=/husksync:userdata dump %1% %2% web)'
data_manager_advancements_preview_remaining: 'dan %1% lagi…'
data_list_title: '[Cuplikan data %1%:](#00fb9a) [(%2%-%3% dari](#00fb9a) [%4%](#00fb9a bold)[)](#00fb9a)\n'
data_list_item: '[%1%](gray show_text=&7Cuplikan data pengguna untuk %2%&8⚡ %4% run_command=/userdata view %2% %3%) [%5%](#d8ff2b show_text=&7Disematkan:\n&8Cuplikan yang disematkan tidak akan dirotasi otomatis. run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962 show_text=&7Versi stampel waktu:&7\n&8Saat data disimpan\n&8%7% run_command=/userdata view %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7Disimpan karena:\n&8Apa yang menyebabkan data disimpan run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fa show_text=&7Ukuran cuplikan:&7\n&8Perkiraan ukuran file cuplikan (dalam KiB) run_command=/userdata view %2% %3%)'
data_list_item_invalid: '[%1%](dark_gray show_text=&7User Data Snapshot for %2%\n&8⚡ %4% suggest_command=/userdata delete %2% %3%) [%5%](dark_gray show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. suggest_command=/userdata delete %2% %3%) [%6% ⚑ %8% ⏏ %9%](gray strikethrough show_text=&#ff3300&Invalid Data Snapshot\n&#ff7e5e&Click to delete\n\n&7⚠ %10% suggest_command=/userdata delete %2% %3%)'
data_deleted: '[❌ Berhasil menghapus cuplikan data pengguna](#00fb9a) [%1%](#00fb9a show_text=&7Versi UUID:\n&8%2%) [untuk](#00fb9a) [%3%.](#00fb9a show_text=&7UUID Pemain:\n&8%4%)'
data_restored: '[⏪ Berhasil dipulihkan](#00fb9a) [%1%](#00fb9a show_text=&7UUID Pemain:\n&8%2%)[data pengguna saat ini dari cuplikan](#00fb9a) [%3%.](#00fb9a show_text=&7Versi UUID:\n&8%4%)'
data_pinned: '[※ Berhasil menyematkan cuplikan data pengguna](#00fb9a) [%1%](#00fb9a show_text=&7Versi UUID:\n&8%2%) [untuk](#00fb9a) [%3%.](#00fb9a show_text=&7UUID Pemain:\n&8%4%)'
data_unpinned: '[※ Berhasil melepaskan cuplikan data pengguna yang disematkan](#00fb9a) [%1%](#00fb9a show_text=&7Versi UUID:\n&8%2%) [untuk](#00fb9a) [%3%.](#00fb9a show_text=&7UUID Pemain:\n&8%4%)'
data_dumped: '[☂ Berhasil membuang cuplikan data pengguna %1% untuk %2% ke:](#00fb9a) &7%3%'
list_footer: '\n%1%[Halaman](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%'
list_previous_page_button: '[◀](white show_text=&7Lihat halaman sebelumnya run_command=%2% %1%) '
list_next_page_button: ' [▶](white show_text=&7Lihat halaman selanjutnya run_command=%2% %1%)'
list_page_jumpers: '(%1%)'
list_page_jumper_button: '[%1%](show_text=&7Loncat ke halaman %1% run_command=%2% %1%)'
list_page_jumper_current_page: '[%1%](#00fb9a)'
list_page_jumper_separator: ' '
list_page_jumper_group_separator: '…'
save_cause_disconnect: 'memutuskan sambungan'
save_cause_world_save: 'penyimpanan dunia'
save_cause_death: 'kematian'
save_cause_server_shutdown: 'pematian server'
save_cause_inventory_command: 'perintah inventaris'
save_cause_enderchest_command: 'perintah enderchest'
save_cause_backup_restore: 'pemulihan cadangan'
save_cause_api: 'API'
save_cause_mpdb_migration: 'migrasi MPDB'
save_cause_legacy_migration: 'migrasi peninggalan'
save_cause_converted_from_v2: 'dikonversi dari v2'
up_to_date: '[HuskSync](#00fb9a bold) [| Kamu menjalankan versi terbaru dari HuskSync (v%1%).](#00fb9a)'
update_available: '[HuskSync](#ff7e5e bold) [| Versi baru HuskSync tersedia: v%1% (menjalankan: v%2%).](#ff7e5e)'
reload_complete: '[HuskSync](#00fb9a bold) [| Memuat ulang file konfigurasi dan pesan.](#00fb9a)\n[⚠ Pastikan file konfigurasi sudah diperbarui di semua server!](#00fb9a)\n[Diperlukan pengaktifan ulang agar perubahan konfigurasi dapat diterapkan.](#00fb9a italic)'
system_status_header: '[HuskSync](#00fb9a bold) [| Laporan status sistem:](#00fb9a)'
error_invalid_syntax: '[Kesalahan:](#ff3300) [Sintaks salah. Penggunaan:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&Klik untuk menyarankan suggest_command=%1%)'
error_invalid_player: '[Kesalahan:](#ff3300) [Tidak dapat menemukan pemain dengan nama tersebut.](#ff7e5e)'
error_invalid_data: '[Error:](#ff3300) [Failed to unpack user data as the snapshot is invalid or corrupt.](#ff7e5e) [(Details…)](gray show_text=&7⚠ %1%)'
error_no_permission: '[Kesalahan:](#ff3300) [Kamu tidak memiliki izin untuk menjalankan perintah ini](#ff7e5e)'
error_console_command_only: '[Kesalahan:](#ff3300) [Perintah itu hanya dapat dijalankan melalui konsol](#ff7e5e)'
error_in_game_command_only: 'Kesalahan: Perintah itu hanya dapat dijalankan dalam game.'
error_no_data_to_display: '[Kesalahan:](#ff3300) [Tidak dapat menemukan data pengguna untuk ditampilkan.](#ff7e5e)'
error_invalid_version_uuid: '[Kesalahan:](#ff3300) [Tidak dapat menemukan data pengguna untuk versi UUID itu.](#ff7e5e)'
husksync_command_description: 'Mengelola plugin HuskSync'
userdata_command_description: 'Lihat, kelola & pulihkan data pengguna pemain'
inventory_command_description: 'Lihat & edit inventaris pemain'
enderchest_command_description: 'Lihat & edit Ender Chest pemain'

View File

@@ -1,49 +1,65 @@
synchronization_complete: '[⏵ Dati sincronizzati!](#00fb9a)' locales:
synchronization_failed: '[⏵ Sincronizzazione fallita! Perfavore contatta un amministratore.](#ff7e5e)' synchronization_complete: '[⏵ Dati sincronizzati!](#00fb9a)'
inventory_viewer_menu_title: '&0Inventario di %1%' synchronization_failed: '[⏵ Sincronizzazione fallita! Perfavore contatta un amministratore.](#ff7e5e)'
ender_chest_viewer_menu_title: '&0Enderchest di %1%' inventory_viewer_menu_title: '&0Inventario di %1%'
inventory_viewer_opened: '[Stai vedendo l''istantanea di](#00fb9a) [%1%](#00fb9a bold) [inventario del ⌚ %2%](#00fb9a)' ender_chest_viewer_menu_title: '&0Enderchest di %1%'
ender_chest_viewer_opened: '[Stai vedendo l''istantanea di](#00fb9a) [%1%](#00fb9a bold) [Ender Chest del ⌚ %2%](#00fb9a)' inventory_viewer_opened: '[Stai vedendo l''istantanea di](#00fb9a) [%1%](#00fb9a bold) [inventario del ⌚ %2%](#00fb9a)'
data_update_complete: '[🔔 I tuoi dati sono stati aggiornati!](#00fb9a)' ender_chest_viewer_opened: '[Stai vedendo l''istantanea di](#00fb9a) [%1%](#00fb9a bold) [Ender Chest del ⌚ %2%](#00fb9a)'
data_update_failed: '[🔔 Aggiornamento dei tuoi dati fallito! Perfavore contatta un amministratore.](#ff7e5e)' data_update_complete: '[🔔 I tuoi dati sono stati aggiornati!](#00fb9a)'
user_registration_complete: '[⭐ User registration complete!](#00fb9a)' data_update_failed: '[🔔 Aggiornamento dei tuoi dati fallito! Perfavore contatta un amministratore.](#ff7e5e)'
data_manager_title: '[Stai vedendo l''istantanea](#00fb9a) [%1%](#00fb9a show_text=&7Versione di UUID:\n&8%2%) [di](#00fb9a) [%3%](#00fb9a bold show_text=&7Player UUID:\n&8%4%)[:](#00fb9a)' user_registration_complete: '[⭐ User registration complete!](#00fb9a)'
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7:\n&8Quando i dati sono stati salvati)' data_manager_title: '[Stai vedendo l''istantanea](#00fb9a) [%1%](#00fb9a show_text=&7Versione di UUID:\n&8%2%) [di](#00fb9a) [%3%](#00fb9a bold show_text=&7Player UUID:\n&8%4%)[:](#00fb9a)'
data_manager_pinned: '[※ Istantanea fissata](#d8ff2b show_text=&7Pinned:\n&8Quest''istantanea non sarà cancellata automaticamente.)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7:\n&8Quando i dati sono stati salvati)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Causa di salvataggio:\n&8Cosa ha causato il salvataggio dei dati)' data_manager_pinned: '[※ Istantanea fissata](#d8ff2b show_text=&7Pinned:\n&8Quest''istantanea non sarà cancellata automaticamente.)'
data_manager_size: '[ %1%](color=#62a9f5-#7ab8fa show_text=&7Peso dell''istantanea:\n&8Peso stimato del file (in KiB))\n' data_manager_cause: '[ %1%](#23a825-#36f539 show_text=&7Causa di salvataggio:\n&8Cosa ha causato il salvataggio dei dati)'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Vita) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Fame) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7Livello di XP) [🏹 %5%](dark_aqua show_text=&7Modalità di gioco)' data_manager_server: '[%1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_advancements_statistics: '[⭐ Progressi: %1%](color=#ffc43b-#f5c962 show_text=&7Progressi compiuti in:\n&8%2%) [⌛ Tempo di gioco: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Tempo di gioco\n&8⚠ Basato sulle statistiche di gioco)\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Peso dell''istantanea:\n&8Peso stimato del file (in KiB))\n'
data_manager_item_buttons: '[View:](gray) [[🪣 Inventario…]](color=#a17b5f-#f5b98c show_text=&7Clicca per visualizzare run_command=/inventory %1% %2%) [[⌀ Ender Chest…]](#b649c4-#d254ff show_text=&7Clicca per visualizzare run_command=/enderchest %1% %2%)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Vita) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Fame) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7Livello di XP) [🏹 %5%](dark_aqua show_text=&7Modalità di gioco)'
data_manager_management_buttons: '[Gestisci:](gray) [[❌ Cancella…]](#ff3300 show_text=&7Fare clic per eliminare questa istantanea.\n&8Questo non influisce sui dati attuali dell''utente.\n&#ff3300&⚠ Questo non può essere annullato! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ Ripristina…]](#00fb9a show_text=&7Clicca per ripristinare i dati dell''utente.\n&8I dati dell''utente saranno ripristinati a quest''istantanea.\n&#ff3300&⚠ I dati di %1% saranno sovrascritti! suggest_command=/husksync:userdata restore %1% %2%) [[※ fissa/sblocca...]](#d8ff2b show_text=&7Clicca per fissare o sbloccare quest''istantanea\n&8Le istantanee fissate non saranno cancellate automaticamente run_command=/userdata pin %1% %2%)' data_manager_advancements_statistics: '[⭐ Progressi: %1%](color=#ffc43b-#f5c962 show_text=&7Progressi compiuti in:\n&8%2%) [⌛ Tempo di gioco: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Tempo di gioco\n&8⚠ Basato sulle statistiche di gioco)\n'
data_manager_system_buttons: '[Sistema:](gray) [[⏷ Dump del File…]](dark_gray show_text=&7Clicca per ottenere il dump dei dati del giocatore.\n&8I dati salvati sono posizioanti nella cartella ~/plugins/HuskSync/dumps/ run_command=/husksync:userdata dump %1% %2% file) [[☂ Dump su Web…]](dark_gray show_text=&7Clicca per ottenere il dump del file su mc-logs\n&8 Ti verrà consegnato l''url per visionare il dump. run_command=/husksync:userdata dump %1% %2% web)' data_manager_item_buttons: '[View:](gray) [[🪣 Inventario…]](color=#a17b5f-#f5b98c show_text=&7Clicca per visualizzare run_command=/inventory %1% %2%) [[⌀ Ender Chest…]](#b649c4-#d254ff show_text=&7Clicca per visualizzare run_command=/enderchest %1% %2%)'
data_manager_advancements_preview_remaining: 'e %1% altro…' data_manager_management_buttons: '[Gestisci:](gray) [[❌ Cancella…]](#ff3300 show_text=&7Fare clic per eliminare questa istantanea.\n&8Questo non influisce sui dati attuali dell''utente.\n&#ff3300&⚠ Questo non può essere annullato! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ Ripristina…]](#00fb9a show_text=&7Clicca per ripristinare i dati dell''utente.\n&8I dati dell''utente saranno ripristinati a quest''istantanea.\n&#ff3300&⚠ I dati di %1% saranno sovrascritti! suggest_command=/husksync:userdata restore %1% %2%) [[※ fissa/sblocca...]](#d8ff2b show_text=&7Clicca per fissare o sbloccare quest''istantanea\n&8Le istantanee fissate non saranno cancellate automaticamente run_command=/userdata pin %1% %2%)'
data_list_title: '[Lista delle istantanee di %1%:](#00fb9a) [(%2%-%3% of](#00fb9a) [%4%](#00fb9a bold)[)](#00fb9a)\n' data_manager_system_buttons: '[Sistema:](gray) [[⏷ Dump del File…]](dark_gray show_text=&7Clicca per ottenere il dump dei dati del giocatore.\n&8I dati salvati sono posizioanti nella cartella ~/plugins/HuskSync/dumps/ run_command=/husksync:userdata dump %1% %2% file) [[☂ Dump su Web…]](dark_gray show_text=&7Clicca per ottenere il dump del file su mc-logs\n&8 Ti verrà consegnato l''url per visionare il dump. run_command=/husksync:userdata dump %1% %2% web)'
data_list_item: '[%1%](gray show_text=&7Instantanea di %2%&8⚡ id: %4% run_command=/userdata view %2% %3%) [%5%](#d8ff2b show_text=&7Fissata:\n&8Se fissata, l''istantanea non viene mai modificata. run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962 show_text=&7Data di salvataggio:&7\n&8Momento preciso in cui è stato salvato il dato\n&8%7% run_command=/userdata view %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7Causa di salvataggio:\n&8Che cosa ha causato il salvataggio run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fa show_text=&7Peso dell''istantanea:&7\n&8Peso stimato del file (in KiB) run_command=/userdata view %2% %3%)' data_manager_advancements_preview_remaining: 'e %1% altro…'
data_deleted: '[❌ Istantanea eliminata con successo](#00fb9a) [%1%](#00fb9a show_text=&7Versione di UUID:\n&8%2%) [per](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)' data_list_title: '[Lista delle istantanee di %1%:](#00fb9a) [(%2%-%3% of](#00fb9a) [%4%](#00fb9a bold)[)](#00fb9a)\n'
data_restored: '[⏪ Ripristato con successo](#00fb9a) [Dati dall''istantanea di](#00fb9a)[%1%](#00fb9a show_text=&7Player UUID:\n&8%2%) [%3%.](#00fb9a show_text=&7Versione di UUID:\n&8%4%)' data_list_item: '[%1%](gray show_text=&7Instantanea di %2%&8⚡ id: %4% run_command=/userdata view %2% %3%) [%5%](#d8ff2b show_text=&7Fissata:\n&8Se fissata, l''istantanea non viene mai modificata. run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962 show_text=&7Data di salvataggio:&7\n&8Momento preciso in cui è stato salvato il dato\n&8%7% run_command=/userdata view %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7Causa di salvataggio:\n&8Che cosa ha causato il salvataggio run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fa show_text=&7Peso dell''istantanea:&7\n&8Peso stimato del file (in KiB) run_command=/userdata view %2% %3%)'
data_pinned: '[※ Instantanea fissata](#00fb9a) [%1%](#00fb9a show_text=&7UUID della versione:\n&8%2%) [per](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)' data_list_item_invalid: '[%1%](dark_gray show_text=&7User Data Snapshot for %2%\n&8⚡ %4% suggest_command=/userdata delete %2% %3%) [%5%](dark_gray show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. suggest_command=/userdata delete %2% %3%) [%6% ⚑ %8% ⏏ %9%](gray strikethrough show_text=&#ff3300&Invalid Data Snapshot\n&#ff7e5e&Click to delete\n\n&7⚠ %10% suggest_command=/userdata delete %2% %3%)'
data_unpinned: '[※ L''istantanea dei dati utente è stata sbloccata con successo](#00fb9a) [%1%](#00fb9a show_text=&7Versione di UUID:\n&8%2%) [per](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)' data_deleted: '[❌ Istantanea eliminata con successo](#00fb9a) [%1%](#00fb9a show_text=&7Versione di UUID:\n&8%2%) [per](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)'
data_dumped: '[☂ Hai ottenuto il dump dell''istantanea %1% di %2% nel formato:](#00fb9a) &7%3%' data_restored: '[⏪ Ripristato con successo](#00fb9a) [Dati dall''istantanea di](#00fb9a)[%1%](#00fb9a show_text=&7Player UUID:\n&8%2%) [%3%.](#00fb9a show_text=&7Versione di UUID:\n&8%4%)'
list_footer: '\n%1%[Pagina](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%' data_pinned: '[※ Instantanea fissata](#00fb9a) [%1%](#00fb9a show_text=&7UUID della versione:\n&8%2%) [per](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)'
list_previous_page_button: '[◀](white show_text=&7Visualizza pagina precedente run_command=%2% %1%) ' data_unpinned: '[※ L''istantanea dei dati utente è stata sbloccata con successo](#00fb9a) [%1%](#00fb9a show_text=&7Versione di UUID:\n&8%2%) [per](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)'
list_next_page_button: ' [▶](white show_text=&7Visualizza pagina successiva run_command=%2% %1%)' data_dumped: '[☂ Hai ottenuto il dump dell''istantanea %1% di %2% nel formato:](#00fb9a) &7%3%'
list_page_jumpers: '(%1%)' list_footer: '\n%1%[Pagina](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%'
list_page_jumper_button: '[%1%](show_text=&7Vai alla pagina %1% run_command=%2% %1%)' list_previous_page_button: '[◀](white show_text=&7Visualizza pagina precedente run_command=%2% %1%) '
list_page_jumper_current_page: '[%1%](#00fb9a)' list_next_page_button: ' [▶](white show_text=&7Visualizza pagina successiva run_command=%2% %1%)'
list_page_jumper_separator: ' ' list_page_jumpers: '(%1%)'
list_page_jumper_group_separator: '' list_page_jumper_button: '[%1%](show_text=&7Vai alla pagina %1% run_command=%2% %1%)'
up_to_date: '[HuskSync](#00fb9a bold) [| Il plugin è all''ultima versione disponibile (v%1%).](#00fb9a)' list_page_jumper_current_page: '[%1%](#00fb9a)'
update_available: '[HuskSync](#ff7e5e bold) [| Disponibile una nuova versione: v%1% (running: v%2%).](#ff7e5e)' list_page_jumper_separator: ' '
reload_complete: '[HuskSync](#00fb9a bold) [| Configurazione e messaggi ricaricati.](#00fb9a)\n[⚠ Ensure config files are up-to-date on all servers!](#00fb9a)\n[A restart is needed for config changes to take effect.](#00fb9a italic)' list_page_jumper_group_separator: '…'
error_invalid_syntax: '[Errore:](#ff3300) [Sintassi errata. Usa:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&Click to suggest suggest_command=%1%)' save_cause_disconnect: 'disconnect'
error_invalid_player: '[Errore:](#ff3300) [Impossibile trovare un giocatore con questo nome.](#ff7e5e)' save_cause_world_save: 'world save'
error_no_permission: '[Errore:](#ff3300) [Non hai il permesso di usare questo comando](#ff7e5e)' save_cause_death: 'death'
error_console_command_only: '[Errore:](#ff3300) [Questo comando può essere eseguito solo dalla](#ff7e5e)' save_cause_server_shutdown: 'server shutdown'
error_in_game_command_only: 'Errore: Questo comando può essere utilizzato solo in gioco.' save_cause_inventory_command: 'inventory command'
error_no_data_to_display: '[Errore:](#ff3300) [Impossibile trovare dati da visualizzare.](#ff7e5e)' save_cause_enderchest_command: 'enderchest command'
error_invalid_version_uuid: '[Errore:](#ff3300) [Impossibile trovare dati utente per questa versione di UUID.](#ff7e5e)' save_cause_backup_restore: 'backup restore'
husksync_command_description: 'Gestisci il plugin HuskSync' save_cause_api: 'API'
userdata_command_description: 'Vedi, gestisci e recupera i dati del giocatore' save_cause_mpdb_migration: 'MPDB migration'
inventory_command_description: 'Vedi e modifica l''Inventario di un giocatore' save_cause_legacy_migration: 'legacy migration'
enderchest_command_description: 'Vedi e modifica l''Ender Chest di un giocatore' save_cause_converted_from_v2: 'converted from v2'
up_to_date: '[HuskSync](#00fb9a bold) [| Il plugin è all''ultima versione disponibile (v%1%).](#00fb9a)'
update_available: '[HuskSync](#ff7e5e bold) [| Disponibile una nuova versione: v%1% (running: v%2%).](#ff7e5e)'
reload_complete: '[HuskSync](#00fb9a bold) [| Configurazione e messaggi ricaricati.](#00fb9a)\n[⚠ Ensure config files are up-to-date on all servers!](#00fb9a)\n[A restart is needed for config changes to take effect.](#00fb9a italic)'
system_status_header: '[HuskSync](#00fb9a bold) [| System status report:](#00fb9a)'
error_invalid_syntax: '[Errore:](#ff3300) [Sintassi errata. Usa:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&Click to suggest suggest_command=%1%)'
error_invalid_player: '[Errore:](#ff3300) [Impossibile trovare un giocatore con questo nome.](#ff7e5e)'
error_invalid_data: '[Error:](#ff3300) [Failed to unpack user data as the snapshot is invalid or corrupt.](#ff7e5e) [(Details…)](gray show_text=&7⚠ %1%)'
error_no_permission: '[Errore:](#ff3300) [Non hai il permesso di usare questo comando](#ff7e5e)'
error_console_command_only: '[Errore:](#ff3300) [Questo comando può essere eseguito solo dalla](#ff7e5e)'
error_in_game_command_only: 'Errore: Questo comando può essere utilizzato solo in gioco.'
error_no_data_to_display: '[Errore:](#ff3300) [Impossibile trovare dati da visualizzare.](#ff7e5e)'
error_invalid_version_uuid: '[Errore:](#ff3300) [Impossibile trovare dati utente per questa versione di UUID.](#ff7e5e)'
husksync_command_description: 'Gestisci il plugin HuskSync'
userdata_command_description: 'Vedi, gestisci e recupera i dati del giocatore'
inventory_command_description: 'Vedi e modifica l''Inventario di un giocatore'
enderchest_command_description: 'Vedi e modifica l''Ender Chest di un giocatore'

View File

@@ -1,49 +1,65 @@
synchronization_complete: '[⏵データが同期されました!](#00fb9a)' locales:
synchronization_failed: '[⏵ Failed to synchronize your data! Please contact an administrator.](#ff7e5e)' synchronization_complete: '[⏵データが同期されました!](#00fb9a)'
inventory_viewer_menu_title: '&0%1%''s Inventory' synchronization_failed: '[⏵ データの同期に失敗しました!管理者に連絡してください。](#ff7e5e)'
ender_chest_viewer_menu_title: '&0%1%''s Ender Chest' inventory_viewer_menu_title: '&0%1%のインベントリ'
inventory_viewer_opened: '[Viewing snapshot of](#00fb9a) [%1%](#00fb9a bold)[''s inventory as of ⌚ %2%](#00fb9a)' ender_chest_viewer_menu_title: '&0%1%のエンダーチェスト'
ender_chest_viewer_opened: '[Viewing snapshot of](#00fb9a) [%1%](#00fb9a bold)[''s Ender Chest as of ⌚ %2%](#00fb9a)' inventory_viewer_opened: '[⌚ %2%](#00fb9a) [%1%](#00fb9a bold) [のインベントリのスナップショットを閲覧する](#00fb9a)'
data_update_complete: '[🔔 Your data has been updated!](#00fb9a)' ender_chest_viewer_opened: '[⌚ %2%](#00fb9a) [%1%](#00fb9a bold) [のエンダーチェストのスナップショットを閲覧する](#00fb9a)'
data_update_failed: '[🔔 Failed to update your data! Please contact an administrator.](#ff7e5e)' data_update_complete: '[🔔 データが更新されました!](#00fb9a)'
user_registration_complete: '[⭐ User registration complete!](#00fb9a)' data_update_failed: '[🔔 データの更新に失敗しました!管理者に連絡してください。](#ff7e5e)'
data_manager_title: '[Viewing user data snapshot](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%](#00fb9a bold show_text=&7Player UUID:\n&8%4%)[:](#00fb9a)' user_registration_complete: '[⭐ ユーザー登録が完了しました!](#00fb9a)'
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Version timestamp:\n&8When the data was saved)' data_manager_title: '[%3%](#00fb9a bold show_text=&7プレイヤーUUID:\n&8%4%) [のユーザーデータスナップショット](#00fb9a)[%1%](#00fb9a show_text=&7バージョンUUID:\n&8%2%)[を表示:](#00fb9a)'
data_manager_pinned: '[※ Snapshot pinned](#d8ff2b show_text=&7Pinned:\n&8This user data snapshot won''t be automatically rotated.)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7バージョンタイムスタンプ:\n&8データの保存時期)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved)' data_manager_pinned: '[※ ピン留めされたスナップショット](#d8ff2b show_text=&7ピン留め:\n&8このユーザーデータのスナップショットは自動的にローテーションされません。)'
data_manager_size: '[ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n' data_manager_cause: '[ %1%](#23a825-#36f539 show_text=&7保存理由:\n&8データが保存された理由)'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Health points) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Hunger points) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP level) [🏹 %5%](dark_aqua show_text=&7Game mode)' data_manager_server: '[%1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_advancements_statistics: '[⭐ Advancements: %1%](color=#ffc43b-#f5c962 show_text=&7Advancements you have progress in:\n&8%2%) [⌛ Play Time: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7In-game play time\n&8⚠ Based on in-game statistics)\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7スナップショットサイズ:\n&8スナップショットの推定ファイルサイズ単位:KiB)\n'
data_manager_item_buttons: '[View:](gray) [[🪣 Inventory…]](color=#a17b5f-#f5b98c show_text=&7Click to view run_command=/inventory %1% %2%) [[⌀ Ender Chest…]](#b649c4-#d254ff show_text=&7Click to view run_command=/enderchest %1% %2%)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7体力) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7空腹度) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7経験値レベル) [🏹 %5%](dark_aqua show_text=&7ゲームモード)'
data_manager_management_buttons: '[Manage:](gray) [[❌ Delete…]](#ff3300 show_text=&7Click to delete this snapshot of user data.\n&8This will not affect the user''s current data.\n&#ff3300&⚠ This cannot be undone! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ Restore…]](#00fb9a show_text=&7Click to restore this user data.\n&8This will set the user''s data to this snapshot.\n&#ff3300&⚠ %1%''s current data will be overwritten! suggest_command=/husksync:userdata restore %1% %2%) [[※ Pin/Unpin…]](#d8ff2b show_text=&7Click to pin or unpin this user data snapshot\n&8Pinned snapshots won''t be automatically rotated run_command=/userdata pin %1% %2%)' data_manager_advancements_statistics: '[⭐ 進捗: %1%](color=#ffc43b-#f5c962 show_text=&7達成した進捗:\n&8%2%) [⌛ プレイ時間: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7ゲーム内のプレイ時間\n&8⚠ ゲーム内の統計に基づく)\n'
data_manager_system_buttons: '[System:](gray) [[⏷ File Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to a file.\n&8Data dumps can be found in ~/plugins/HuskSync/dumps/ run_command=/husksync:userdata dump %1% %2% file) [[☂ Web Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to the mc-logs service\n&8You will be provided with a URL containing the data. run_command=/husksync:userdata dump %1% %2% web)' data_manager_item_buttons: '[表示:](gray) [[🪣 インベントリ…]](color=#a17b5f-#f5b98c show_text=&7クリックで表示 run_command=/husksync:inventory %1% %2%) [[⌀ エンダーチェスト…]](#b649c4-#d254ff show_text=&7クリックで表示 run_command=/husksync:enderchest %1% %2%)'
data_manager_advancements_preview_remaining: 'and %1% more…' data_manager_management_buttons: '[管理:](gray) [[❌ 消去…]](#ff3300 show_text=&7クリックでこのユーザーデータのスナップショットを消去します。\n&8これはユーザーの現在のデータには影響しません。\n&#ff3300&⚠ この操作は元に戻せません! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ 復元…]](#00fb9a show_text=&7クリックでこのユーザーデータを復元します。\n&8これにより、ユーザーデータはこのスナップショットに設定されます。\n&#ff3300&⚠ %1% の現在のデータは上書きされます! suggest_command=/husksync:userdata restore %1% %2%) [[※ ピン留め/ピン外し…]](#d8ff2b show_text=&7クリックでこのユーザーデータのスナップショットをピン留め、若しくはピンを外します。\n&8ピン留めされたスナップショットは自動的にローテーションしません。 run_command=/userdata pin %1% %2%)'
data_list_title: '[%1%''s user data snapshots:](#00fb9a) [(%2%-%3% of](#00fb9a) [%4%](#00fb9a bold)[)](#00fb9a)\n' data_manager_system_buttons: '[システム:](gray) [[⏷ ファイルダンプ…]](dark_gray show_text=&7クリックで未加工のユーザーデータスナップショットをダンプファイルにします。\n&8データダンプの場所は ~/plugins/HuskSync/dumps/ です run_command=/husksync:userdata dump %1% %2% file) [[☂ Webダンプ…]](dark_gray show_text=&7クリックでユーザーデータスナップショットをmc-logsサービスにダンプします。\n&8データを含むURLが提供されます。 run_command=/husksync:userdata dump %1% %2% web)'
data_list_item: '[%1%](gray show_text=&7User Data Snapshot for %2%&8⚡ %4% run_command=/userdata view %2% %3%) [%5%](#d8ff2b show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962 show_text=&7Version timestamp:&7\n&8When the data was saved\n&8%7% run_command=/userdata view %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:&7\n&8Estimated file size of the snapshot (in KiB) run_command=/userdata view %2% %3%)' data_manager_advancements_preview_remaining: 'さらに %1% 件…'
data_deleted: '[❌ Successfully deleted user data snapshot](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)' data_list_title: '[%1% のユーザーデータスナップショット:](#00fb9a) [(%4%件中](#00fb9a bold) [%2%-%3%件](#00fb9a)[)](#00fb9a)\n'
data_restored: '[⏪ Successfully restored](#00fb9a) [%1%](#00fb9a show_text=&7Player UUID:\n&8%2%)[''s current user data from snapshot](#00fb9a) [%3%.](#00fb9a show_text=&7Version UUID:\n&8%4%)' data_list_item: '[%1%](gray show_text=&7%2% のユーザーデータスナップショット&8⚡ %4% run_command=/husksync:userdata view %2% %3%) [%5%](#d8ff2b show_text=&7ピン留め:\n&8ピン留めされたスナップショットは自動的にローテーションしません。 run_command=/husksync: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_pinned: '[※ Successfully pinned user data snapshot](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)' data_list_item_invalid: '[%1%](dark_gray show_text=&7User Data Snapshot for %2%\n&8⚡ %4% suggest_command=/userdata delete %2% %3%) [%5%](dark_gray show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. suggest_command=/userdata delete %2% %3%) [%6% ⚑ %8% ⏏ %9%](gray strikethrough show_text=&#ff3300&Invalid Data Snapshot\n&#ff7e5e&Click to delete\n\n&7⚠ %10% suggest_command=/userdata delete %2% %3%)'
data_unpinned: '[※ Successfully unpinned user data snapshot](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)' data_deleted: '[](#00fb9a) [%3%](#00fb9a show_text=&7Player UUID:\n&8%4%) [のユーザーデータスナップショット](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [の消去に成功しました。](#00fb9a)'
data_dumped: '[☂ Successfully dumped the user data snapshot %1% for %2% to:](#00fb9a) &7%3%' data_restored: '[⏪](#00fb9a) [スナップショット](#00fb9a) [%3%](#00fb9a show_text=&7Version UUID:\n&8%4%) [から](#00fb9a) [%1%](#00fb9a show_text=&7Player UUID:\n&8%2%) [の現在のユーザーデータの復元に成功しました。](#00fb9a)'
list_footer: '\n%1%[Page](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%' data_pinned: '[※](#00fb9a) [%3%](#00fb9a show_text=&7Player UUID:\n&8%4%) [のユーザーデータスナップショット](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [のピン留めに成功しました。](#00fb9a)'
list_previous_page_button: '[](white show_text=&7View previous page run_command=%2% %1%) ' data_unpinned: '[](#00fb9a) [%3%](#00fb9a show_text=&7Player UUID:\n&8%4%) [のユーザーデータスナップショット](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [のピン外しに成功しました。](#00fb9a)'
list_next_page_button: ' [▶](white show_text=&7View next page run_command=%2% %1%)' data_dumped: '[☂ %2% のユーザーデータスナップショット %1% のダンプに成功:](#00fb9a) &7%3%'
list_page_jumpers: '(%1%)' list_footer: '\n%1%[ページ](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%'
list_page_jumper_button: '[%1%](show_text=&7Jump to page %1% run_command=%2% %1%)' list_previous_page_button: '[◀](white show_text=&7前のページへ run_command=%2% %1%) '
list_page_jumper_current_page: '[%1%](#00fb9a)' list_next_page_button: ' [▶](white show_text=&7次のページへ run_command=%2% %1%)'
list_page_jumper_separator: ' ' list_page_jumpers: '(%1%)'
list_page_jumper_group_separator: '' list_page_jumper_button: '[%1%](show_text=&7%1% ページ目へ run_command=%2% %1%)'
up_to_date: '[HuskSync](#00fb9a bold) [| HuskSyncの最新バージョンを実行しています(v%1%).](#00fb9a)' list_page_jumper_current_page: '[%1%](#00fb9a)'
update_available: '[HuskSync](#ff7e5e bold) [| HuskSyncの最新バージョンが更新されています: v%1% (running: v%2%).](#ff7e5e)' list_page_jumper_separator: ' '
reload_complete: '[HuskSync](#00fb9a bold) [| 設定ファイルとメッセージファイルを再読み込みしました。](#00fb9a)\n[⚠ Ensure config files are up-to-date on all servers!](#00fb9a)\n[A restart is needed for config changes to take effect.](#00fb9a italic)' list_page_jumper_group_separator: '…'
error_invalid_syntax: '[Error:](#ff3300) [構文が正しくありません。使用法:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&Click to suggest suggest_command=%1%)' save_cause_disconnect: 'disconnect'
error_invalid_player: '[Error:](#ff3300) [そのプレイヤーは見つかりませんでした](#ff7e5e)' save_cause_world_save: 'world save'
error_no_permission: '[Error:](#ff3300) [このコマンドを実行する権限がありません](#ff7e5e)' save_cause_death: 'death'
error_console_command_only: '[Error:](#ff3300) [そのコマンドは%1%コンソールからのみ実行できます](#ff7e5e)' save_cause_server_shutdown: 'server shutdown'
error_in_game_command_only: 'Error: That command can only be used in-game.' save_cause_inventory_command: 'inventory command'
error_no_data_to_display: '[Error:](#ff3300) [Could not find any user data to display.](#ff7e5e)' save_cause_enderchest_command: 'enderchest command'
error_invalid_version_uuid: '[Error:](#ff3300) [Could not find any user data for that version UUID.](#ff7e5e)' save_cause_backup_restore: 'backup restore'
husksync_command_description: 'Manage the HuskSync plugin' save_cause_api: 'API'
userdata_command_description: 'View, manage & restore player userdata' save_cause_mpdb_migration: 'MPDB migration'
inventory_command_description: 'View & edit a player''s inventory' save_cause_legacy_migration: 'legacy migration'
enderchest_command_description: 'View & edit a player''s Ender Chest' save_cause_converted_from_v2: 'converted from v2'
up_to_date: '[HuskSync](#00fb9a bold) [| HuskSyncの最新バージョンを実行しています(v%1%).](#00fb9a)'
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) [| System status report:](#00fb9a)'
error_invalid_syntax: '[Error:](#ff3300) [構文が正しくありません。使用法:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&クリックでサジェスト suggest_command=%1%)'
error_invalid_player: '[Error:](#ff3300) [そのプレイヤーは見つかりませんでした](#ff7e5e)'
error_invalid_data: '[Error:](#ff3300) [Failed to unpack user data as the snapshot is invalid or corrupt.](#ff7e5e) [(Details…)](gray show_text=&7⚠ %1%)'
error_no_permission: '[Error:](#ff3300) [このコマンドを実行する権限がありません](#ff7e5e)'
error_console_command_only: '[Error:](#ff3300) [そのコマンドは%1%コンソールからのみ実行できます](#ff7e5e)'
error_in_game_command_only: 'Error: そのコマンドはゲーム内でしか使えません。'
error_no_data_to_display: '[Error:](#ff3300) [表示するユーザーデータが見つかりませんでした。](#ff7e5e)'
error_invalid_version_uuid: '[Error:](#ff3300) [そのバージョンUUIDのユーザーデータが見つかりませんでした。](#ff7e5e)'
husksync_command_description: 'HuskSyncプラグインを管理する'
userdata_command_description: 'プレーヤーのユーザーデータを表示・管理・復元する'
inventory_command_description: 'プレイヤーのインベントリを閲覧・編集する'
enderchest_command_description: 'プレイヤーのエンダーチェストを閲覧・編集する'

View File

@@ -0,0 +1,65 @@
locales:
synchronization_complete: '[⏵ 데이터 연동됨!](#00fb9a)'
synchronization_failed: '[⏵ 데이터 연동에 실패하였습니다! 관리자에게 문의해 주세요.](#ff7e5e)'
inventory_viewer_menu_title: '&0%1%님의 인벤토리'
ender_chest_viewer_menu_title: '&0%1%님의 엔더상자'
inventory_viewer_opened: '[%1%](#00fb9a bold)[님의 ⌚ %2%의 인벤토리를 엽니다](#00fb9a)'
ender_chest_viewer_opened: '[%1%](#00fb9a bold)[님의 ⌚ %2%의 엔더상자를 엽니다](#00fb9a)'
data_update_complete: '[🔔 당신의 데이터가 업데이트 되었습니다!](#00fb9a)'
data_update_failed: '[🔔 데이터 업데이트에 실패하였습니다! 관리자에게 문의해 주세요.](#ff7e5e)'
user_registration_complete: '[⭐ 유저 등록이 완료되었습니다!](#00fb9a)'
data_manager_title: '[%3%](#00fb9a bold show_text=&7플레이어 UUID:\n&8%4%)[님의 ](#00fb9a) [%1%](#00fb9a show_text=&7버전 UUID:\n&8%2%) [데이터 스냅샷을 표시합니다](#00fb9a)[:](#00fb9a)'
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7저장 시각:\n&8데이터가 저장된 시각)'
data_manager_pinned: '[※ 스냅샷 고정됨](#d8ff2b show_text=&7고정됨:\n&8이 유저의 데이터 스냅샷은 자동으로 갱신되지 않습니다.)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7저장 사유:\n&8데이터가 저장된 사유입니다.)'
data_manager_server: '[☁ %1%](#ff87b3-#f5538e show_text=&7서버:\n&8데이터 저장이 이루어진 서버입니다.)'
data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7스냅샷 크기:\n&8스냅샷 파일의 대략적인 크기입니다. (단위 KiB))\n'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7체력) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7허기) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7경험치 레벨) [🏹 %5%](dark_aqua show_text=&7게임 모드)'
data_manager_advancements_statistics: '[⭐ 도전 과제: %1%](color=#ffc43b-#f5c962 show_text=&7진행한 도전 과제:\n&8%2%) [⌛ 플레이 타임: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7인게임 플레이 시간\n&8⚠ 인게임 통계에 기반합니다.)\n'
data_manager_item_buttons: '[보기:](gray) [[🪣 인벤토리…]](color=#a17b5f-#f5b98c show_text=&7클릭하여 확인 run_command=/inventory %1% %2%) [[⌀ 엔더상자…]](#b649c4-#d254ff show_text=&7클릭하여 확인 run_command=/enderchest %1% %2%)'
data_manager_management_buttons: '[관리:](gray) [[❌ 삭제…]](#ff3300 show_text=&7클릭하여 이 유저 스냅샷 데이터를 삭제\n&8이 기능은 현재 유저의 인벤토리 데이터에는 영향을 미치지 않습니다.\n&#ff3300&⚠ 이 작업은 되돌릴 수 없습니다! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ 복구…]](#00fb9a show_text=&7클릭하여 유저 데이터 복구\n&8유저의 데이터가 이 스냅샷의 데이터로 변경됩니다.\n&#ff3300&⚠ %1%님의 현재 데이터에 덧씌워 집니다! suggest_command=/husksync:userdata restore %1% %2%) [[※ 고정/고정 해제…]](#d8ff2b show_text=&7클릭하여 유저 데이터를 고정 또는 고정 해제\n&8고정된 스냅샷은 자동적으로 갱신되지 않습니다. run_command=/userdata pin %1% %2%)'
data_manager_system_buttons: '[시스템:](gray) [[⏷ 파일 덤프…]](dark_gray show_text=&7클릭하여 이 유저 데이터 스냅샷을 덤프하기\n&8데이터 덤프 파일은 ~/plugins/HuskSync/dumps/ 에서 찾을 수 있습니다. run_command=/husksync:userdata dump %1% %2% file) [[☂ 웹 덤프…]](dark_gray show_text=&7클릭하여 유저 데이터 스냅샷을 mc-log 서비스에 덤프하기\n&8데이터를 포함한 URL이 제공됩니다. run_command=/husksync:userdata dump %1% %2% web)'
data_manager_advancements_preview_remaining: '외 %1%개...'
data_list_title: '[%1%님의 유저 데이터 스냅샷 목록:](#00fb9a) [(%2%-%3% 중](#00fb9a) [%4%](#00fb9a bold)[)](#00fb9a)\n'
data_list_item: '[%1%](gray show_text=&7%2%&7님의 유저 데이터 스냅샷&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=&7User Data Snapshot for %2%\n&8⚡ %4% suggest_command=/userdata delete %2% %3%) [%5%](dark_gray show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. suggest_command=/userdata delete %2% %3%) [%6% ⚑ %8% ⏏ %9%](gray strikethrough show_text=&#ff3300&Invalid Data Snapshot\n&#ff7e5e&Click to delete\n\n&7⚠ %10% suggest_command=/userdata delete %2% %3%)'
data_deleted: '[❌ 성공적으로](#00fb9a) [%3%](#00fb9a show_text=&7플레이어 UUID:\n&8%4%) [님의 유저 데이터 스냅샷](#00fb9a) [%1%](#00fb9a show_text=&7버전 UUID:\n&8%2%)[을 삭제하였습니다.](#00fb9a)'
data_restored: '[⏪ 성공적으로 복구되었습니다.](#00fb9a) [%1%](#00fb9a show_text=&7플레이어 UUID:\n&8%2%)[님의 현재 유저 데이터 스냅샷이](#00fb9a) [%3%](#00fb9a show_text=&7버전 UUID:\n&8%4%)[으로 변경되었습니다.](#00fb9a)'
data_pinned: '[※ 성공적으로](#00fb9a) [%3%](#00fb9a show_text=&7플레이어 UUID:\n&8%4%)[님의 유저 데이터 스냅샷](#00fb9a) [%1%](#00fb9a show_text=&7버전 UUID:\n&8%2%)[을 고정하였습니다.](#00fb9a)'
data_unpinned: '[※ 성공적으로](#00fb9a) [%3%](#00fb9a show_text=&7플레이어 UUID:\n&8%4%) [님의 유저 데이터 스냅샷](#00fb9a) [%1%](#00fb9a show_text=&7버전 UUID:\n&8%2%)[을 고정 해제하였습니다.](#00fb9a)'
data_dumped: '[☂ 성공적으로 %2%님의 유저 데이터 스냅샷 %1%를 다음으로 덤프하였습니다:](#00fb9a) &7%3%'
list_footer: '\n%1%[페이지](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%'
list_previous_page_button: '[◀](white show_text=&7이전 페이지 보기 run_command=%2% %1%) '
list_next_page_button: ' [▶](white show_text=&7다음 페이지 보기 run_command=%2% %1%)'
list_page_jumpers: '(%1%)'
list_page_jumper_button: '[%1%](show_text=&7%1% 페이지 보기 run_command=%2% %1%)'
list_page_jumper_current_page: '[%1%](#00fb9a)'
list_page_jumper_separator: ' '
list_page_jumper_group_separator: '…'
save_cause_disconnect: 'disconnect'
save_cause_world_save: 'world save'
save_cause_death: 'death'
save_cause_server_shutdown: 'server shutdown'
save_cause_inventory_command: 'inventory command'
save_cause_enderchest_command: 'enderchest command'
save_cause_backup_restore: 'backup restore'
save_cause_api: 'API'
save_cause_mpdb_migration: 'MPDB migration'
save_cause_legacy_migration: 'legacy migration'
save_cause_converted_from_v2: 'converted from v2'
up_to_date: '[HuskSync](#00fb9a bold) [| 가장 최신 버전의 HuskSync를 실행 중입니다 (v%1%).](#00fb9a)'
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) [| System status report:](#00fb9a)'
error_invalid_syntax: '[오류:](#ff3300) [잘못된 사용법. 사용법:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&클릭하여 입력할 수 있습니다. suggest_command=%1%)'
error_invalid_player: '[오류:](#ff3300) [해당 이름의 사용자를 찾을 수 없습니다.](#ff7e5e)'
error_invalid_data: '[Error:](#ff3300) [Failed to unpack user data as the snapshot is invalid or corrupt.](#ff7e5e) [(Details…)](gray show_text=&7⚠ %1%)'
error_no_permission: '[오류:](#ff3300) [해당 명령어를 사용할 권한이 없습니다.](#ff7e5e)'
error_console_command_only: '[오류:](#ff3300) [해당 명령어는 콘솔을 통해서만 사용할 수 있습니다.](#ff7e5e)'
error_in_game_command_only: '오류: 해당 명령어는 게임 내부에서만 사용할 수 있습니다.'
error_no_data_to_display: '[오류:](#ff3300) [표시할 유저 데이터를 찾을 수 없습니다.](#ff7e5e)'
error_invalid_version_uuid: '[오류:](#ff3300) [해당 버전 UUID의 유저 데이터 스냅샷을 찾을 수 없습니다.](#ff7e5e)'
husksync_command_description: 'HuskSync 플러그인을 관리합니다.'
userdata_command_description: '확인, 관리 또는 복구합니다.'
inventory_command_description: '플레이어의 인벤토리를 열람 또는 편집합니다.'
enderchest_command_description: '플레이어의 엔더상자를 열람 또는 편집합니다.'

View File

@@ -0,0 +1,65 @@
locales:
synchronization_complete: '[⏵ Data gesynchroniseerd!](#00fb9a)'
synchronization_failed: '[⏵ Synchroniseren van jouw gegevens is niet gelukt! Neem contact op met een beheerder.](#ff7e5e)'
inventory_viewer_menu_title: '&0%1%''s Inventaris'
ender_chest_viewer_menu_title: '&0%1%''s Enderkist'
inventory_viewer_opened: '[Momentopname bekijken van](#00fb9a) [%1%](#00fb9a bold)[''s inventaris per ⌚ %2%](#00fb9a)'
ender_chest_viewer_opened: '[Momentopname bekijken van](#00fb9a) [%1%](#00fb9a bold)[''s Enderkist per ⌚ %2%](#00fb9a)'
data_update_complete: '[🔔 Jouw gegevens zijn bijgewerkt!](#00fb9a)'
data_update_failed: '[🔔 Het is niet gelukt om jouw gegevens bij te werken! Neem contact op met een beheerder.](#ff7e5e)'
user_registration_complete: '[⭐ Gebruikersregistratie voltooid!](#00fb9a)'
data_manager_title: '[Momentopname van gebruikersgegevens bekijken](#00fb9a) [%1%](#00fb9a show_text=&7Versie UUID:\n&8%2%) [voor](#00fb9a) [%3%](#00fb9a bold show_text=&7Speler UUID:\n&8%4%)[:](#00fb9a)'
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Versie tijdmarkering:\n&8Toen de gegevens werden opgeslagen)'
data_manager_pinned: '[※ Momentopname vastgezet](#d8ff2b show_text=&7Vastgezet:\n&8Deze momentopname van gebruikersgegevens wordt niet automatisch gerouleerd.)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Reden opslaan:\n&8Waarom de data is opgeslagen)'
data_manager_server: '[☁ %1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Grootte van momentopname:\n&8Geschatte bestandsgrootte van de momentopname (in KiB))\n'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Gezondheids punten) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Honger punten) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP level) [🏹 %5%](dark_aqua show_text=&7Speltype)'
data_manager_advancements_statistics: '[⭐ Advancements: %1%](color=#ffc43b-#f5c962 show_text=&7Advancements waarin je voortgang hebt:\n&8%2%) [⌛ Speeltijd: %3%uren](color=#62a9f5-#7ab8fa show_text=&7In-game speeltijd\n&8⚠ Gebaseerd op in-game statistieken)\n'
data_manager_item_buttons: '[View:](gray) [[🪣 Inventaris…]](color=#a17b5f-#f5b98c show_text=&7Klikken om te bekijken run_command=/inventory %1% %2%) [[⌀ Enderkist…]](#b649c4-#d254ff show_text=&7Klikken om te bekijken run_command=/enderchest %1% %2%)'
data_manager_management_buttons: '[Beheren:](gray) [[❌ Verwijderen…]](#ff3300 show_text=&7Klik om deze momentopname van gebruikersgegevens te verwijderen.\n&8Dit heeft geen invloed op de huidige gegevens van de gebruiker.\n&#ff3300&⚠ Dit kan niet ongedaan gemaakt worden! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ Herstellen…]](#00fb9a show_text=&7Klik om deze gebruikersgegevens te herstellen.\n&8Hierdoor worden de gegevens van de gebruiker ingesteld op deze momentopname.\n&#ff3300&⚠ %1%''s huidige gegevens worden overschreven! suggest_command=/husksync:userdata restore %1% %2%) [[※ Vastzetten/losmaken…]](#d8ff2b show_text=&7Klik om deze momentopname van gebruikersgegevens vast te zetten of los te maken\n&8Vastgezette momentopnamen worden niet automatisch gerouleerd run_command=/userdata pin %1% %2%)'
data_manager_system_buttons: '[Systeem:](gray) [[⏷ Bestandsdump…]](dark_gray show_text=&7Klik om deze ruwe gebruikersgegevenssnapshot naar een bestand te dumperen.\n&8Gegevensdumps zijn te vinden in ~/plugins/HuskSync/dumps/ run_command=/husksync:userdata dump %1% %2% file) [[☂ Webdump…]](dark_gray show_text=&7Klik om deze ruwe gebruikersgegevenssnapshot naar de mc-logs-service te dumpen\n&8Je ontvangt een URL met de gegevens. run_command=/husksync:userdata dump %1% %2% web)'
data_manager_advancements_preview_remaining: 'en %1% meer…'
data_list_title: '[%1%''s momentopnamen van gebruikersgegevens:](#00fb9a) [(%2%-%3% van](#00fb9a) [%4%](#00fb9a bold)[)](#00fb9a)\n'
data_list_item: '[%1%](gray show_text=&7Gebruikersgegevens momentopname voor %2%&8⚡ %4% run_command=/userdata view %2% %3%) [%5%](#d8ff2b show_text=&7Vastgezet:\n&8Vastgezette momentopnamen worden niet automatisch gerouleerd. run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962 show_text=&7Versie tijdmarkering:&7\n&8Wanneer de data was opgeslagen\n&8%7% run_command=/userdata view %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7Reden opslaan:\n&8Waarom de data is opgeslagen run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fa show_text=&7Grootte van momentopname:&7\n&8Geschatte bestandsgrootte van de momentopname (in KiB) run_command=/userdata view %2% %3%)'
data_list_item_invalid: '[%1%](dark_gray show_text=&7User Data Snapshot for %2%\n&8⚡ %4% suggest_command=/userdata delete %2% %3%) [%5%](dark_gray show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. suggest_command=/userdata delete %2% %3%) [%6% ⚑ %8% ⏏ %9%](gray strikethrough show_text=&#ff3300&Invalid Data Snapshot\n&#ff7e5e&Click to delete\n\n&7⚠ %10% suggest_command=/userdata delete %2% %3%)'
data_deleted: '[❌ Momentopname van gebruikersgegevens is verwijderd](#00fb9a) [%1%](#00fb9a show_text=&7Versie UUID:\n&8%2%) [voor](#00fb9a) [%3%.](#00fb9a show_text=&7Speler UUID:\n&8%4%)'
data_restored: '[⏪ Succesvol hersteld](#00fb9a) [%1%](#00fb9a show_text=&7Speler UUID:\n&8%2%)[''s huidige gebruikersgegevens uit momentopname](#00fb9a) [%3%.](#00fb9a show_text=&7Versie UUID:\n&8%4%)'
data_pinned: '[※ Momentopname van gebruikersgegevens is vastgezet](#00fb9a) [%1%](#00fb9a show_text=&7Versie UUID:\n&8%2%) [voor](#00fb9a) [%3%.](#00fb9a show_text=&7Speler UUID:\n&8%4%)'
data_unpinned: '[※ Momentopname van gebruikersgegevens is losgemaakt](#00fb9a) [%1%](#00fb9a show_text=&7Versie UUID:\n&8%2%) [voor](#00fb9a) [%3%.](#00fb9a show_text=&7Speler UUID:\n&8%4%)'
data_dumped: '[☂ De momentopname van gebruikersgegevens %1% voor %2% is met succes gedumpt naar:](#00fb9a) &7%3%'
list_footer: '\n%1%[Pagina](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%'
list_previous_page_button: '[◀](white show_text=&7Bekijk vorige pagina run_command=%2% %1%) '
list_next_page_button: ' [▶](white show_text=&7Bekijk volgende pagina run_command=%2% %1%)'
list_page_jumpers: '(%1%)'
list_page_jumper_button: '[%1%](show_text=&7Ga naar pagina %1% run_command=%2% %1%)'
list_page_jumper_current_page: '[%1%](#00fb9a)'
list_page_jumper_separator: ' '
list_page_jumper_group_separator: '…'
save_cause_disconnect: 'disconnect'
save_cause_world_save: 'world save'
save_cause_death: 'death'
save_cause_server_shutdown: 'server shutdown'
save_cause_inventory_command: 'inventory command'
save_cause_enderchest_command: 'enderchest command'
save_cause_backup_restore: 'backup restore'
save_cause_api: 'API'
save_cause_mpdb_migration: 'MPDB migration'
save_cause_legacy_migration: 'legacy migration'
save_cause_converted_from_v2: 'converted from v2'
up_to_date: '[HuskSync](#00fb9a bold) [| Je gebruikt de nieuwste versie van HuskSync (v%1%).](#00fb9a)'
update_available: '[HuskSync](#ff7e5e bold) [| Er is een nieuwe versie van HuskSync beschikbaar: v%1% (huidige versie: v%2%).](#ff7e5e)'
reload_complete: '[HuskSync](#00fb9a bold) [| Configuratie- en berichtbestanden opnieuw geladen.](#00fb9a)\n[⚠ Controleer of de configuratiebestanden up-to-date zijn op alle servers!](#00fb9a)\n[Een herstart is nodig voor de configuratiewijzigingen van kracht te laten worden.](#00fb9a italic)'
system_status_header: '[HuskSync](#00fb9a bold) [| System status report:](#00fb9a)'
error_invalid_syntax: '[Error:](#ff3300) [Onjuiste syntaxis. Gebruik:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&Click to suggest suggest_command=%1%)'
error_invalid_player: '[Error:](#ff3300) [Kan geen speler met die naam vinden.](#ff7e5e)'
error_invalid_data: '[Error:](#ff3300) [Failed to unpack user data as the snapshot is invalid or corrupt.](#ff7e5e) [(Details…)](gray show_text=&7⚠ %1%)'
error_no_permission: '[Error:](#ff3300) [Je hebt geen toestemming om deze opdracht uit te voeren](#ff7e5e)'
error_console_command_only: '[Error:](#ff3300) [Dat command kan alleen via de console worden uitgevoerd](#ff7e5e)'
error_in_game_command_only: 'Error: Dat command kan alleen in-game worden gebruikt.'
error_no_data_to_display: '[Error:](#ff3300) [Kon geen gebruikersgegevens vinden om weer te geven.](#ff7e5e)'
error_invalid_version_uuid: '[Error:](#ff3300) [Kon geen gebruikersgegevens vinden voor dat versie-UUID.](#ff7e5e)'
husksync_command_description: 'Beheer de HuskSync plugin'
userdata_command_description: 'Bekijk, beheer en herstel de gebruikersgegevens van spelers'
inventory_command_description: 'Bekijk en bewerk de inventaris van een speler'
enderchest_command_description: 'Bekijk en bewerk de Enderkist van een speler'

View File

@@ -1,49 +1,65 @@
synchronization_complete: '[⏵ Dados sincronizados!](#00fb9a)' locales:
synchronization_failed: '[⏵ Falha na sincronização de seus dados! Por favor entre em contato com um administrador.](#ff7e5e)' synchronization_complete: '[⏵ Dados sincronizados!](#00fb9a)'
inventory_viewer_menu_title: '&0%1%''s Inventory' synchronization_failed: '[⏵ Falha na sincronização de seus dados! Por favor entre em contato com um administrador.](#ff7e5e)'
ender_chest_viewer_menu_title: '&0%1%''s Ender Chest' inventory_viewer_menu_title: '&0%1%''s Inventory'
inventory_viewer_opened: '[Visualizando snapshot de](#00fb9a) [%1%](#00fb9a bold) [''s inventory a partir de ⌚ %2%](#00fb9a)' ender_chest_viewer_menu_title: '&0%1%''s Ender Chest'
ender_chest_viewer_opened: '[Visualizando snapshot de](#00fb9a) [%1%](#00fb9a bold) [''s Ender Chest a partir de ⌚ %2%](#00fb9a)' inventory_viewer_opened: '[Visualizando snapshot de](#00fb9a) [%1%](#00fb9a bold) [''s inventory a partir de ⌚ %2%](#00fb9a)'
data_update_complete: '[🔔 Seus dados foram atualizados!](#00fb9a)' ender_chest_viewer_opened: '[Visualizando snapshot de](#00fb9a) [%1%](#00fb9a bold) [''s Ender Chest a partir de ⌚ %2%](#00fb9a)'
data_update_failed: '[🔔 Falha na atualização de seus dados! Por favor entre em contato com um administrador.](#ff7e5e)' data_update_complete: '[🔔 Seus dados foram atualizados!](#00fb9a)'
user_registration_complete: '[⭐ User registration complete!](#00fb9a)' data_update_failed: '[🔔 Falha na atualização de seus dados! Por favor entre em contato com um administrador.](#ff7e5e)'
data_manager_title: '[Visualizando snapshot dos dados do usuário](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%](#00fb9a bold show_text=&7Player UUID:\n&8%4%)[:](#00fb9a)' user_registration_complete: '[⭐ User registration complete!](#00fb9a)'
data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Version timestamp:\n&8Quando os dados foram salvos)' data_manager_title: '[Visualizando snapshot dos dados do usuário](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%](#00fb9a bold show_text=&7Player UUID:\n&8%4%)[:](#00fb9a)'
data_manager_pinned: '[※ Snapshot marcada](#d8ff2b show_text=&7Marcada:\n&8Essa snapshot de dados do usuário não será girada automaticamente.)' data_manager_timestamp: '[⌚ %1%](#ffc43b-#f5c962 show_text=&7Version timestamp:\n&8Quando os dados foram salvos)'
data_manager_cause: '[⚑ %1%](#23a825-#36f539 show_text=&7Causa do salvamento:\n&8O motivo para que os dados fossem salvos)' data_manager_pinned: '[※ Snapshot marcada](#d8ff2b show_text=&7Marcada:\n&8Essa snapshot de dados do usuário não será girada automaticamente.)'
data_manager_size: '[ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n' data_manager_cause: '[ %1%](#23a825-#36f539 show_text=&7Causa do salvamento:\n&8O motivo para que os dados fossem salvos)'
data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Pontos de Vida) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Pontos de vida) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP level) [🏹 %5%](dark_aqua show_text=&7Game mode)' data_manager_server: '[%1%](#ff87b3-#f5538e show_text=&7Server:\n&8Name of the server the data was saved on)'
data_manager_advancements_statistics: '[⭐ Progressos: %1%](color=#ffc43b-#f5c962 show_text=&7Progressos que você tem realizado em:\n&8%2%) [⌛ Tempo de jogo: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Tempo de jogo dentro do jogo\n&8⚠ Com base em estatísticas dentro do jogo)\n' data_manager_size: '[⏏ %1%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:\n&8Estimated file size of the snapshot (in KiB))\n'
data_manager_item_buttons: '[View:](gray) [[🪣 Inventory…]](color=#a17b5f-#f5b98c show_text=&7Clique para ver run_command=/inventory %1% %2%) [[⌀ Ender Chest…]](#b649c4-#d254ff show_text=&7Clique para ver run_command=/enderchest %1% %2%)' data_manger_status: '[%1%](red)[/](gray)[%2%](red)[×](gray)[❤](red show_text=&7Pontos de Vida) [%3%](yellow)[×](gray)[🍖](yellow show_text=&7Pontos de vida) [ʟᴠ](green)[.](gray)[%4%](green show_text=&7XP level) [🏹 %5%](dark_aqua show_text=&7Game mode)'
data_manager_management_buttons: '[Gerenciar:](gray) [[❌ Deletar…]](#ff3300 show_text=&7Clique para deletar esta snapshot de dados do usuário\n&8Isto não afetará os dados atuais do usuário.\n&#ff3300&⚠ Isto não pode ser desfeito! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ Restaurar…]](#00fb9a show_text=&7Clique para restaurar estes dados do usuário.\n&8Isto substituirá os dados atuais do usuário para os da snapshot.\n&#ff3300&⚠ %1%''s os dados atuais serão substituídos! suggest_command=/husksync:userdata restore %1% %2%) [[※ Marcar/Desmarcar…]](#d8ff2b show_text=&7Clique para marcar ou desmarcar este snapshot de dados do usuário\n&8Snapshots marcadas não serão giradas automaticamente run_command=/userdata pin %1% %2%)' data_manager_advancements_statistics: '[⭐ Progressos: %1%](color=#ffc43b-#f5c962 show_text=&7Progressos que você tem realizado em:\n&8%2%) [⌛ Tempo de jogo: %3%ʜʀs](color=#62a9f5-#7ab8fa show_text=&7Tempo de jogo dentro do jogo\n&8⚠ Com base em estatísticas dentro do jogo)\n'
data_manager_system_buttons: '[System:](gray) [[⏷ File Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to a file.\n&8Data dumps can be found in ~/plugins/HuskSync/dumps/ run_command=/husksync:userdata dump %1% %2% file) [[☂ Web Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to the mc-logs service\n&8You will be provided with a URL containing the data. run_command=/husksync:userdata dump %1% %2% web)' data_manager_item_buttons: '[View:](gray) [[🪣 Inventory…]](color=#a17b5f-#f5b98c show_text=&7Clique para ver run_command=/inventory %1% %2%) [[⌀ Ender Chest…]](#b649c4-#d254ff show_text=&7Clique para ver run_command=/enderchest %1% %2%)'
data_manager_advancements_preview_remaining: 'e %1% mais…' data_manager_management_buttons: '[Gerenciar:](gray) [[❌ Deletar…]](#ff3300 show_text=&7Clique para deletar esta snapshot de dados do usuário\n&8Isto não afetará os dados atuais do usuário.\n&#ff3300&⚠ Isto não pode ser desfeito! suggest_command=/husksync:userdata delete %1% %2%) [[⏪ Restaurar…]](#00fb9a show_text=&7Clique para restaurar estes dados do usuário.\n&8Isto substituirá os dados atuais do usuário para os da snapshot.\n&#ff3300&⚠ %1%''s os dados atuais serão substituídos! suggest_command=/husksync:userdata restore %1% %2%) [[※ Marcar/Desmarcar…]](#d8ff2b show_text=&7Clique para marcar ou desmarcar este snapshot de dados do usuário\n&8Snapshots marcadas não serão giradas automaticamente run_command=/userdata pin %1% %2%)'
data_list_title: '[%1%''s user data snapshots:](#00fb9a) [(%2%-%3% of](#00fb9a) [%4%](#00fb9a bold)[)](#00fb9a)\n' data_manager_system_buttons: '[System:](gray) [[⏷ File Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to a file.\n&8Data dumps can be found in ~/plugins/HuskSync/dumps/ run_command=/husksync:userdata dump %1% %2% file) [[☂ Web Dump…]](dark_gray show_text=&7Click to dump this raw user data snapshot to the mc-logs service\n&8You will be provided with a URL containing the data. run_command=/husksync:userdata dump %1% %2% web)'
data_list_item: '[%1%](gray show_text=&7User Data Snapshot for %2%&8⚡ %4% run_command=/userdata view %2% %3%) [%5%](#d8ff2b show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962 show_text=&7Version timestamp:&7\n&8When the data was saved\n&8%7% run_command=/userdata view %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:&7\n&8Estimated file size of the snapshot (in KiB) run_command=/userdata view %2% %3%)' data_manager_advancements_preview_remaining: 'e %1% mais…'
data_deleted: '[❌ Snapshot de dados do usuário deletada com sucesso](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)' data_list_title: '[%1%''s user data snapshots:](#00fb9a) [(%2%-%3% of](#00fb9a) [%4%](#00fb9a bold)[)](#00fb9a)\n'
data_restored: '[⏪ Restaurada com sucesso](#00fb9a) [%1%](#00fb9a show_text=&7Player UUID:\n&8%2%)[''s current user data from snapshot](#00fb9a) [%3%.](#00fb9a show_text=&7Version UUID:\n&8%4%)' data_list_item: '[%1%](gray show_text=&7User Data Snapshot for %2%\n&8⚡ %4% run_command=/userdata view %2% %3%) [%5%](#d8ff2b show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. run_command=/userdata view %2% %3%) [%6%](color=#ffc43b-#f5c962 show_text=&7Version timestamp:&7\n&8When the data was saved\n&8%7% run_command=/userdata view %2% %3%) [⚑ %8%](#23a825-#36f539 show_text=&7Save cause:\n&8What caused the data to be saved run_command=/userdata view %2% %3%) [⏏ %9%](color=#62a9f5-#7ab8fa show_text=&7Snapshot size:&7\n&8Estimated file size of the snapshot (in KiB) run_command=/userdata view %2% %3%)'
data_pinned: '[※ Snapshot de dados do usuário marcada com sucesso](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)' data_list_item_invalid: '[%1%](dark_gray show_text=&7User Data Snapshot for %2%\n&8⚡ %4% suggest_command=/userdata delete %2% %3%) [%5%](dark_gray show_text=&7Pinned:\n&8Pinned snapshots won''t be automatically rotated. suggest_command=/userdata delete %2% %3%) [%6% ⚑ %8% ⏏ %9%](gray strikethrough show_text=&#ff3300&Invalid Data Snapshot\n&#ff7e5e&Click to delete\n\n&7⚠ %10% suggest_command=/userdata delete %2% %3%)'
data_unpinned: '[ Snapshot de dados do usuário desmarcada com sucesso](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)' data_deleted: '[ Snapshot de dados do usuário deletada com sucesso](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)'
data_dumped: '[☂ Successfully dumped the user data snapshot %1% for %2% to:](#00fb9a) &7%3%' data_restored: '[⏪ Restaurada com sucesso](#00fb9a) [%1%](#00fb9a show_text=&7Player UUID:\n&8%2%)[''s current user data from snapshot](#00fb9a) [%3%.](#00fb9a show_text=&7Version UUID:\n&8%4%)'
list_footer: '\n%1%[Page](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%' data_pinned: '[※ Snapshot de dados do usuário marcada com sucesso](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)'
list_previous_page_button: '[◀](white show_text=&7View previous page run_command=%2% %1%) ' data_unpinned: '[※ Snapshot de dados do usuário desmarcada com sucesso](#00fb9a) [%1%](#00fb9a show_text=&7Version UUID:\n&8%2%) [for](#00fb9a) [%3%.](#00fb9a show_text=&7Player UUID:\n&8%4%)'
list_next_page_button: ' [▶](white show_text=&7View next page run_command=%2% %1%)' data_dumped: '[☂ Successfully dumped the user data snapshot %1% for %2% to:](#00fb9a) &7%3%'
list_page_jumpers: '(%1%)' list_footer: '\n%1%[Page](#00fb9a) [%2%](#00fb9a)/[%3%](#00fb9a)%4% %5%'
list_page_jumper_button: '[%1%](show_text=&7Jump to page %1% run_command=%2% %1%)' list_previous_page_button: '[◀](white show_text=&7View previous page run_command=%2% %1%) '
list_page_jumper_current_page: '[%1%](#00fb9a)' list_next_page_button: ' [▶](white show_text=&7View next page run_command=%2% %1%)'
list_page_jumper_separator: ' ' list_page_jumpers: '(%1%)'
list_page_jumper_group_separator: '' list_page_jumper_button: '[%1%](show_text=&7Jump to page %1% run_command=%2% %1%)'
up_to_date: '[HuskSync](#00fb9a bold) [| You are running the latest version of HuskSync (v%1%).](#00fb9a)' list_page_jumper_current_page: '[%1%](#00fb9a)'
update_available: '[HuskSync](#ff7e5e bold) [| A new version of HuskSync is available: v%1% (running: v%2%).](#ff7e5e)' list_page_jumper_separator: ' '
reload_complete: '[HuskSync](#00fb9a bold) [| Arquivos de configuração e mensagens recarregados.](#00fb9a)\n[⚠ Ensure config files are up-to-date on all servers!](#00fb9a)\n[A restart is needed for config changes to take effect.](#00fb9a italic)' list_page_jumper_group_separator: '…'
error_invalid_syntax: '[Error:](#ff3300) [Sintaxe incorreta. Utilize:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&Click to suggest suggest_command=%1%)' save_cause_disconnect: 'disconnect'
error_invalid_player: '[Error:](#ff3300) [Não foi possível encontrar um jogador com esse nome.](#ff7e5e)' save_cause_world_save: 'world save'
error_no_permission: '[Error:](#ff3300) [Você não tem permissão para executar este comando](#ff7e5e)' save_cause_death: 'death'
error_console_command_only: '[Error:](#ff3300) [Esse comando só pode ser executado através do console](#ff7e5e)' save_cause_server_shutdown: 'server shutdown'
error_in_game_command_only: 'Error: Esse comando só pode ser usado dentro do jogo.' save_cause_inventory_command: 'inventory command'
error_no_data_to_display: '[Error:](#ff3300) [Não encontramos nenhuma informação deste jogador para exibir.](#ff7e5e)' save_cause_enderchest_command: 'enderchest command'
error_invalid_version_uuid: '[Error:](#ff3300) [Não foi possível encontrar nenhuma informação deste jogador para essa versão UUID.](#ff7e5e)' save_cause_backup_restore: 'backup restore'
husksync_command_description: 'Manage the HuskSync plugin' save_cause_api: 'API'
userdata_command_description: 'View, manage & restore player userdata' save_cause_mpdb_migration: 'MPDB migration'
inventory_command_description: 'View & edit a player''s inventory' save_cause_legacy_migration: 'legacy migration'
enderchest_command_description: 'View & edit a player''s Ender Chest' save_cause_converted_from_v2: 'converted from v2'
up_to_date: '[HuskSync](#00fb9a bold) [| You are running the latest version of HuskSync (v%1%).](#00fb9a)'
update_available: '[HuskSync](#ff7e5e bold) [| A new version of HuskSync is available: v%1% (running: v%2%).](#ff7e5e)'
reload_complete: '[HuskSync](#00fb9a bold) [| Arquivos de configuração e mensagens recarregados.](#00fb9a)\n[⚠ Ensure config files are up-to-date on all servers!](#00fb9a)\n[A restart is needed for config changes to take effect.](#00fb9a italic)'
system_status_header: '[HuskSync](#00fb9a bold) [| System status report:](#00fb9a)'
error_invalid_syntax: '[Error:](#ff3300) [Sintaxe incorreta. Utilize:](#ff7e5e) [%1%](#ff7e5e italic show_text=&#ff7e5e&Click to suggest suggest_command=%1%)'
error_invalid_player: '[Error:](#ff3300) [Não foi possível encontrar um jogador com esse nome.](#ff7e5e)'
error_invalid_data: '[Error:](#ff3300) [Failed to unpack user data as the snapshot is invalid or corrupt.](#ff7e5e) [(Details…)](gray show_text=&7⚠ %1%)'
error_no_permission: '[Error:](#ff3300) [Você não tem permissão para executar este comando](#ff7e5e)'
error_console_command_only: '[Error:](#ff3300) [Esse comando só pode ser executado através do console](#ff7e5e)'
error_in_game_command_only: 'Error: Esse comando só pode ser usado dentro do jogo.'
error_no_data_to_display: '[Error:](#ff3300) [Não encontramos nenhuma informação deste jogador para exibir.](#ff7e5e)'
error_invalid_version_uuid: '[Error:](#ff3300) [Não foi possível encontrar nenhuma informação deste jogador para essa versão UUID.](#ff7e5e)'
husksync_command_description: 'Manage the HuskSync plugin'
userdata_command_description: 'View, manage & restore player userdata'
inventory_command_description: 'View & edit a player''s inventory'
enderchest_command_description: 'View & edit a player''s Ender Chest'

Some files were not shown because too many files have changed in this diff Show More